From 848e39e61fb10e7d5151b6a70d1cce1d197d92b9 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 9 Sep 2026 03:30:09 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_016P5RoqzmUQEbeC2qjAkeGr --- frontend/DESIGN.md | 21 ++ .../src/app/api/reports/[reportId]/route.ts | 11 +- frontend/src/app/globals.css | 59 ++++ .../personal-report-document-view.tsx | 12 +- .../personal-report/personal-report-page.tsx | 77 ++++- .../personal-report-progress-panel.tsx | 35 +++ frontend/src/lib/personal-report-progress.ts | 183 ++++++++++++ .../src/lib/personal-report-route-core.ts | 33 ++- .../tests/personal-report-progress.test.ts | 271 ++++++++++++++++++ 9 files changed, 684 insertions(+), 18 deletions(-) create mode 100644 frontend/src/components/personal-report/personal-report-progress-panel.tsx create mode 100644 frontend/src/lib/personal-report-progress.ts create mode 100644 frontend/tests/personal-report-progress.test.ts diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index b4d5f5eb..7a3ea312 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -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:` → `persisting_report`): + +| 阶段 | 屏幕上 | 文案 | +| --- | --- | --- | +| 准备 | `InlineSpinner` | 正在准备你的星盘证据 | +| 写作 | 分章进度条 + 章节清单,无 spinner | 已完成 N / M 章 | +| 收尾 | `InlineSpinner` | 正在整理成文 | + +硬规定: + +- **进度条按章分格,一格一章,不画百分比。** job 的 percent 在 0→30 和 90→100 是瞬间跳变,只有中段跟随真实工作量;画成线性条等于演出后端没做的动作。格数恒等于章数,条与清单不可能互相矛盾。 +- **不做时间插值,不显示预计剩余时间。** 界面上没有任何按定时器推进的东西;后端没动,屏幕就不动。章节耗时被重试放大到两倍以上,报不准比不报更伤。 +- **正在写的那一章由行状态判定,不由 phase 名判定。** `section:` 命名的是刚写完的那一章;且章节列表按 `section_id` 字典序返回,不是写作顺序,所以"已完成数 + 1"也会指错。唯一正确的判据是 `pending` 且已被认领。 +- **停滞满 90 秒**(`REPORT_PROGRESS_STALL_MS`)当前章文案改为「用时较长,仍在写」。不写第几次尝试——`attemptCount` 不出服务端。 +- **写作失败的章仍然计入完成数**,进度条照常前进;走到头不等于全部成功,收尾按 `summarizePersonalReportFailure()` 的白名单文案说明。 +- 可显示的只有章节名与章节状态(用户交付物的结构)。`attemptCount`、原始错误码、lease、job id、payload 一律不出服务端——与 BUG-043 的边界一致:那条禁止的是把后台评分状态渲染成用户要管理的面板,本处是只读等待屏上的交付物结构。 + Admin 的 antd `` 是独立设计系统,不在此表。 diff --git a/frontend/src/app/api/reports/[reportId]/route.ts b/frontend/src/app/api/reports/[reportId]/route.ts index cb2bbc8a..4f0ef858 100644 --- a/frontend/src/app/api/reports/[reportId]/route.ts +++ b/frontend/src/app/api/reports/[reportId]/route.ts @@ -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 diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 05749ff4..1ec4f197 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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; diff --git a/frontend/src/components/personal-report/personal-report-document-view.tsx b/frontend/src/components/personal-report/personal-report-document-view.tsx index 1999730b..ea1bbabe 100644 --- a/frontend/src/components/personal-report/personal-report-document-view.tsx +++ b/frontend/src/components/personal-report/personal-report-document-view.tsx @@ -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 = { multi_system_consensus: "多系统一致", @@ -61,15 +62,6 @@ const DEPTH_LABELS: Record = { research: "研究", }; -const THEME_LABELS: Record = { - general: "综合", - career: "事业", - wealth: "财富", - marriage: "婚恋", - health: "健康", - education: "教育", - timing: "应期", -}; const ACTION_PRIORITY_LABELS: Record = { 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[] { diff --git a/frontend/src/components/personal-report/personal-report-page.tsx b/frontend/src/components/personal-report/personal-report-page.tsx index 24a08560..18ba5dea 100644 --- a/frontend/src/components/personal-report/personal-report-page.tsx +++ b/frontend/src/components/personal-report/personal-report-page.tsx @@ -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({ phase: "loading" }); const [waitStartedAt, setWaitStartedAt] = useState(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 (
- -

- {generating ? PERSONAL_REPORT_GENERATING_COPY : "正在加载报告…"} + {writing ? null : } +

+ {generating ? (progress?.headline ?? PERSONAL_REPORT_GENERATING_COPY) : "正在加载报告…"}

+ {progress && writing && } {generating && ( <>

已等待 {formatWaitedDuration(waitedMs)}

diff --git a/frontend/src/components/personal-report/personal-report-progress-panel.tsx b/frontend/src/components/personal-report/personal-report-progress-panel.tsx new file mode 100644 index 00000000..1537abd1 --- /dev/null +++ b/frontend/src/components/personal-report/personal-report-progress-panel.tsx @@ -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 ( +
+ +
    + {progress.chapters.map((chapter) => ( +
  • + {chapter.label} + {chapter.statusText} +
  • + ))} +
+
+ ); +} diff --git a/frontend/src/lib/personal-report-progress.ts b/frontend/src/lib/personal-report-progress.ts new file mode 100644 index 00000000..f46665a2 --- /dev/null +++ b/frontend/src/lib/personal-report-progress.ts @@ -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> = { + 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:(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> = { + 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}`; +} diff --git a/frontend/src/lib/personal-report-route-core.ts b/frontend/src/lib/personal-report-route-core.ts index 4f324c81..48f1fbe6 100644 --- a/frontend/src/lib/personal-report-route-core.ts +++ b/frontend/src/lib/personal-report-route-core.ts @@ -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 | 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; + /** + * 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[]>; loadLongformMarkdown?: (input: Readonly<{ @@ -688,14 +701,30 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise ( + 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 ({ 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;/); +});