关掉未登录就会触发约 20 秒计算的入口,并记下 BUG-1342。普通导出的逐字节锁保留。自测 tsc 0 错,lint 0 error / 123 warning,改到的前端测试 fail=0,两份 Python 测试通过,test:db 失败 13 条与 Windows 已知清单一致。 Co-Authored-By: Grok (agent loop)
246 lines
13 KiB
TypeScript
246 lines
13 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import { readFileSync } from "node:fs";
|
||
import test from "node:test";
|
||
|
||
import { classifyReportEnvelope } from "../src/components/personal-report/personal-report-page.tsx";
|
||
import { cssDeclarations } from "./css-contract-test-support.ts";
|
||
import { safeParseReportDocument } from "../src/lib/personal-report-contract.ts";
|
||
import type { LegacyReportDocumentV1, ReportDocumentV2 } from "../src/lib/personal-report-contract.ts";
|
||
|
||
const fixturePath = new URL("../../tests/fixtures/personal_report_document.v1.json", import.meta.url);
|
||
const v2FixturePath = new URL("../../tests/fixtures/personal_report_document.v2.json", import.meta.url);
|
||
const canonicalFixture = JSON.parse(readFileSync(fixturePath, "utf8")) as LegacyReportDocumentV1;
|
||
const canonicalV2Fixture = JSON.parse(readFileSync(v2FixturePath, "utf8")) as ReportDocumentV2;
|
||
|
||
// 原值: 用 PersonalReportDocumentView 渲染旧阅读页的用例留在本文件
|
||
// 新值: 那些用例随旧阅读页删除;解析、路由信封、阅读外壳、目录和打印留在下面
|
||
// 原因: 阅读页从 09 月起走 Markdown 视图,旧视图没有页面引用
|
||
|
||
test("stored v1 and current v2 fixtures pass the canonical contract parse", () => {
|
||
const storedV1 = safeParseReportDocument(canonicalFixture);
|
||
assert.equal(storedV1.ok, true);
|
||
if (storedV1.ok) {
|
||
assert.equal(storedV1.document.schemaVersion, "report_document.v1");
|
||
assert.equal(storedV1.document.reportId, canonicalFixture.reportId);
|
||
}
|
||
|
||
const currentV2 = safeParseReportDocument(canonicalV2Fixture);
|
||
assert.equal(currentV2.ok, true);
|
||
if (currentV2.ok) {
|
||
assert.equal(currentV2.document.schemaVersion, "report_document.v2");
|
||
assert.equal(currentV2.document.reportId, canonicalV2Fixture.reportId);
|
||
}
|
||
});
|
||
|
||
test("GET envelope classification: 401/404/ready/generating/failed/invalid (real API shape)", () => {
|
||
assert.deepEqual(classifyReportEnvelope(401, {}), { phase: "unauthorized" });
|
||
assert.deepEqual(classifyReportEnvelope(404, {}), { phase: "not-found" });
|
||
assert.deepEqual(classifyReportEnvelope(403, {}), { phase: "invalid", message: "无权访问该报告。" });
|
||
|
||
const view = (status: string, failureCode: string | null = null) => ({
|
||
id: "11111111-1111-1111-1111-111111111111",
|
||
requestId: "22222222-2222-2222-2222-222222222222",
|
||
reportType: "personal_full",
|
||
presentationMode: "default",
|
||
status,
|
||
failureCode,
|
||
createdAt: "2026-08-06T00:00:00Z",
|
||
completedAt: null,
|
||
});
|
||
|
||
const ready = classifyReportEnvelope(200, {
|
||
report: view("ready"),
|
||
longformMarkdown: "# 个人长报告\n\n### 解读摘要\n正文",
|
||
});
|
||
assert.equal(ready.phase, "markdown-ready");
|
||
assert.ok(ready.phase === "markdown-ready" && ready.markdown.includes("解读摘要"));
|
||
|
||
const readyV2 = classifyReportEnvelope(200, {
|
||
report: view("ready"),
|
||
longformMarkdown: "## 成品阅读导航\n导航",
|
||
reportDocument: canonicalV2Fixture,
|
||
});
|
||
assert.equal(readyV2.phase, "markdown-ready");
|
||
|
||
assert.deepEqual(classifyReportEnvelope(200, { report: view("generating") }), { phase: "generating" });
|
||
assert.deepEqual(classifyReportEnvelope(200, { report: view("failed", "model_unavailable") }), {
|
||
phase: "failed",
|
||
failureCode: "model_unavailable",
|
||
});
|
||
assert.deepEqual(
|
||
classifyReportEnvelope(200, {
|
||
report: { ...view("failed", "report_schema_invalid"), failureSummary: "4 个主题中 4 个写作失败:输出被截断" },
|
||
}),
|
||
{
|
||
phase: "failed",
|
||
failureCode: "report_schema_invalid",
|
||
failureSummary: "4 个主题中 4 个写作失败:输出被截断",
|
||
},
|
||
);
|
||
assert.deepEqual(
|
||
classifyReportEnvelope(200, {
|
||
report: {
|
||
...view("failed", "calculation_unavailable"),
|
||
failureSummary: "报告正文未能保存,请稍后重试",
|
||
appendixLastErrorCode: "appendix_persist_failed",
|
||
},
|
||
}),
|
||
{
|
||
phase: "failed",
|
||
failureCode: "appendix_persist_failed",
|
||
failureSummary: "报告正文未能保存,请稍后重试",
|
||
appendixLastErrorCode: "appendix_persist_failed",
|
||
},
|
||
);
|
||
assert.deepEqual(classifyReportEnvelope(200, { report: view("failed") }), { phase: "failed", failureCode: null });
|
||
|
||
// Server-side error envelopes carry a stable code at the top level.
|
||
assert.deepEqual(classifyReportEnvelope(503, { error: "x", code: "report_persistence_unavailable" }), {
|
||
phase: "failed",
|
||
failureCode: "report_persistence_unavailable",
|
||
});
|
||
assert.deepEqual(classifyReportEnvelope(500, {}), { phase: "failed", failureCode: null });
|
||
|
||
const missingDocument = classifyReportEnvelope(200, { report: view("ready") });
|
||
assert.equal(missingDocument.phase, "legacy-unavailable");
|
||
|
||
const badDocument = classifyReportEnvelope(200, {
|
||
report: view("ready"),
|
||
reportDocument: { schemaVersion: "wrong" },
|
||
});
|
||
assert.equal(badDocument.phase, "legacy-unavailable");
|
||
|
||
const unknownStatus = classifyReportEnvelope(200, { report: view("mystery") });
|
||
assert.equal(unknownStatus.phase, "invalid");
|
||
|
||
assert.equal(classifyReportEnvelope(200, null).phase, "invalid");
|
||
assert.equal(classifyReportEnvelope(200, { error: "oops" }).phase, "invalid");
|
||
});
|
||
|
||
test("report center and detail surfaces prefer a readable failure summary over the bare schema code", () => {
|
||
const center = readFileSync(
|
||
new URL("../src/components/personal-report/personal-report-center.tsx", import.meta.url),
|
||
"utf8",
|
||
);
|
||
const page = readFileSync(
|
||
new URL("../src/components/personal-report/personal-report-page.tsx", import.meta.url),
|
||
"utf8",
|
||
);
|
||
assert.match(center, /report\.failureSummary \?\? report\.failureCode/);
|
||
assert.match(page, /state\.failureSummary \?\? "生成过程中出现问题/);
|
||
assert.match(page, /错误码:\{state\.failureCode\}/);
|
||
});
|
||
|
||
test("canonical contract rejects malformed documents", () => {
|
||
assert.equal(safeParseReportDocument({}).ok, false);
|
||
const noCharts = { ...structuredClone(canonicalFixture) };
|
||
delete (noCharts as Partial<LegacyReportDocumentV1>).charts;
|
||
assert.equal(safeParseReportDocument(noCharts).ok, false);
|
||
const badVersion = { ...structuredClone(canonicalFixture), schemaVersion: "report_document.v2" };
|
||
assert.equal(safeParseReportDocument(badVersion).ok, false);
|
||
const badMode = { ...structuredClone(canonicalFixture), presentationMode: "ultra" };
|
||
assert.equal(safeParseReportDocument(badMode).ok, false);
|
||
const badReportId = { ...structuredClone(canonicalFixture), reportId: "not-a-uuid" };
|
||
assert.equal(safeParseReportDocument(badReportId).ok, false);
|
||
const badRefs = structuredClone(canonicalFixture) as LegacyReportDocumentV1;
|
||
badRefs.thematicNarrative[0].evidenceRefs = ["ev-does-not-exist"];
|
||
assert.equal(safeParseReportDocument(badRefs).ok, false, "dangling evidenceRefs must fail guards");
|
||
});
|
||
|
||
test("ready reports expose only the back link and accessible export icon", () => {
|
||
const actionsSource = readFileSync(
|
||
new URL("../src/components/personal-report/report-actions.tsx", import.meta.url),
|
||
"utf8",
|
||
);
|
||
assert.match(actionsSource, /href="\/reports"/);
|
||
assert.match(actionsSource, /返回报告中心/);
|
||
assert.match(actionsSource, /ReportExportDrawer/);
|
||
// 原值:`assert.match(actionsSource, /size="icon"/)` 与 `/title="导出"/`
|
||
// 新值:不再是图标按钮(doesNotMatch size="icon"),可见文字「导出」,描边样式
|
||
// 原因:TASK-report-reader-polish-20260929 §3-3——图标按钮太不显眼,产品要求做明显
|
||
assert.doesNotMatch(actionsSource, /size="icon"/);
|
||
assert.match(actionsSource, /variant="outline"/);
|
||
assert.match(actionsSource, /<Download aria-hidden="true" \/>导出/);
|
||
assert.doesNotMatch(actionsSource, /PERSONAL_REPORT_EXPORT_LABEL|#export|reportId|reportTitle/);
|
||
assert.match(actionsSource, /aria-label="导出报告"/);
|
||
assert.doesNotMatch(actionsSource, /RawAppendixDownload|printPersonalReport|PERSONAL_REPORT_PRINT_LABEL|Printer|useSyncExternalStore|detectPrintRestriction/);
|
||
});
|
||
|
||
test("the reader renders inside the app shell, in every phase", () => {
|
||
// /reports/[reportId] was the last standalone full-screen route: opening a
|
||
// report dropped the nav out of the app. Every phase carries the shell now,
|
||
// so the way back is the rail rather than one in-page link.
|
||
const pageSource = readFileSync(
|
||
new URL("../src/components/personal-report/personal-report-page.tsx", import.meta.url),
|
||
"utf8",
|
||
);
|
||
// 原值:`SecondaryShell` 的 import 与 `<SecondaryShell title="个人报告">` 的出现次数
|
||
// 新值:`SecondaryHeader` 的 import 与 `<SecondaryHeader title="个人报告" />` 的出现次数
|
||
// 原因:TASK-sidebar-unify D2 把 provider + 侧栏 + inset 上移到
|
||
// `app/(app)/layout.tsx`,`SecondaryShell` 拆剩 46px 顶栏并改名
|
||
// `SecondaryHeader`。断言主语(每个阶段都在 app 外壳里、都恰好带一次外壳、
|
||
// 没有一个阶段返回裸 `<main>`)一字未改。
|
||
assert.match(pageSource, /import \{ SecondaryHeader \} from "@\/components\/secondary-header";/);
|
||
// No phase may return a bare <main>: that would nest inside the shell's own.
|
||
assert.doesNotMatch(pageSource, /<main className="personal-report-(?:state|reader)"/);
|
||
const shells = pageSource.match(/<SecondaryHeader title="个人报告" \/>/g) ?? [];
|
||
const phases = pageSource.match(/className="personal-report-(?:state|reader)"/g) ?? [];
|
||
assert.equal(shells.length, phases.length, "every rendered phase is wrapped exactly once");
|
||
assert.ok(phases.length >= 8, `all report phases render, got ${phases.length}`);
|
||
});
|
||
|
||
test("printing a report inside the shell drops the chrome and its height lock", () => {
|
||
const pageSource = readFileSync(
|
||
new URL("../src/components/personal-report/personal-report-page.tsx", import.meta.url),
|
||
"utf8",
|
||
);
|
||
const globals = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||
// The @page rule is untouched.
|
||
assert.match(pageSource, /<style media="print">\{"@page \{ size: A4; margin: 13mm 12mm 14mm; \}"\}<\/style>/);
|
||
// The shell is hidden AND unlocked. Hiding alone is not enough: .chat-app and
|
||
// .chat-panel are `height: 100%; overflow: hidden`, which would clip a
|
||
// nine-section report to one page.
|
||
assert.match(pageSource, /<style media="print">\{REPORT_SHELL_PRINT_CSS\}<\/style>/);
|
||
const start = pageSource.indexOf("const REPORT_SHELL_PRINT_CSS");
|
||
assert.ok(start >= 0, "the shell print stylesheet must be declared");
|
||
const end = pageSource.indexOf("/** Wall-clock budget", start);
|
||
assert.ok(end > start, "the declaration must end before the next export");
|
||
const shellPrintCss = pageSource.slice(start, end);
|
||
assert.match(shellPrintCss, /\.chat-app, \.chat-panel/);
|
||
assert.match(shellPrintCss, /overflow: visible !important/);
|
||
assert.match(shellPrintCss, /\[data-slot='sidebar'\]/);
|
||
assert.match(shellPrintCss, /\.chat-header/);
|
||
assert.match(shellPrintCss, /display: none !important/);
|
||
// The TOC is screen furniture and stays off paper.
|
||
const printBlock = globals.slice(globals.indexOf("personal-report: unique block"));
|
||
assert.match(printBlock, /@media print/);
|
||
assert.match(printBlock, /\.personal-report-toc \{ display: none !important; \}/);
|
||
});
|
||
|
||
test("the report TOC is a persistent right-hand rail that leaves the paper palette alone", () => {
|
||
const globals = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||
const viewSource = readFileSync(
|
||
new URL("../src/components/personal-report/personal-report-markdown-view.tsx", import.meta.url),
|
||
"utf8",
|
||
);
|
||
// Built from the outline's own heading ids — no second slugger, and nothing
|
||
// that would need report-chart-grid-rehype to change (BUG-616/617).
|
||
// 原值:`/<ReportToc headings=\{outline\.headings\} \/>/`
|
||
// 新值:同一 headings 之外允许并只允许多一个 `language` 属性
|
||
// 原因:TASK-report-english-edition-20260929 E6——目录标签随报告语言切换;目录仍只从同一份 outline 的 heading id 构建
|
||
assert.match(viewSource, /<ReportToc headings=\{outline\.headings\} language=\{language\} \/>/);
|
||
assert.match(viewSource, /aria-current=\{item\.id === activeId \? "location" : undefined\}/);
|
||
assert.match(viewSource, /rehypePlugins=\{\[reportChartGrid\]\}/, "BUG-616/617 grid stays wired");
|
||
|
||
const toc = cssDeclarations(".personal-report-toc", globals);
|
||
assert.match(toc, /grid-column: 2/, "the rail sits right of the paper");
|
||
assert.match(toc, /position: sticky/);
|
||
const layout = cssDeclarations(".personal-report-md-layout", globals);
|
||
assert.match(layout, /grid-template-columns: minmax\(0, 1fr\) minmax\(11rem, 15rem\)/);
|
||
// The rail is app chrome; the three paper tokens are not on it (D11/D3).
|
||
const current = cssDeclarations(".personal-report-toc a.is-current", globals);
|
||
assert.match(current, /border-left-color: var\(--color-action\)/);
|
||
assert.doesNotMatch(toc + current, /--report-(?:paper|rule|accent)/);
|
||
assert.match(globals, /--report-accent: #85432f;/);
|
||
});
|