Files
Jyotisha/frontend/tests/personal-report-view.test.ts
T
Jesse_ChenandClaude Opus 5 a1a78c7ebc
Independent Staging Quality Gate / validate (push) Canceled after 3m14s
Independent Staging Quality Gate / publish (push) Canceled after 0s
feat(ui): 报告中心改行式列表,阅读页并入外壳并把目录挪到右侧常驻
报告中心原来是卡片方阵,状态只靠三块底色区分;报告一多,扫读成本
按卡片数线性涨。现在一列一行:带色点的状态 chip、标题、创建时间 ·
深度 · 主题,操作靠右。失败原因从右边一小块挪进行内,能完整读到。

chip 里原来的 StatusIcon(generating 转圈、ready 打勾、failed 警告)
换成 5px 色点——原型如此,且列表是轮询不是演出。失败行不加「重新
生成」,顶栏已经有唯一的生成入口。

行内小字只写接口真给的东西。GET /api/reports 不返回节数和盘数,所以
不写「9 节 · 22 张盘」(VOICE.md 第 2 条),原型图上那行是 mock。

/reports/[reportId] 是最后一个脱离外壳的全屏路由,九个 phase 全部并进
SecondaryShell,根节点从 <main> 改成 <div>(外壳自己就是 main)。

任务书 E10「没有目录」已过期:目录在 cfcd369d 就存在。本轮把它从左栏
挪到正文右侧并定稿视觉,位置用 grid-column 显式指定而不是靠 DOM 次序,
这样窄屏抽屉仍能排在源码最前面,不会掉到全文末尾。860px 以下常驻栏
消失、折叠抽屉保留——删掉抽屉等于窄屏彻底失去章节定位。

挂外壳带出一个真实风险:window.print() 打的是整篇,而 .chat-app /
.chat-panel 是 height:100%;overflow:hidden,会把九节报告裁成一页。阅读
页因此多挂一条 media="print" 样式,把外壳既隐藏又解锁。放组件里而不是
globals.css:它只在阅读页挂载期存在,对话页的打印不受影响,也不用
:has() 去够祖先,更不越界到并行轮次的 CSS 区段。

纸面三个 token 一个没动,--report-accent 仍是 #85432f;目录栏用应用
调色板,因为它是 chrome 不是纸。report-chart-grid-rehype.ts 一行未动,
新增断言守住它仍然接线(BUG-616/617)。

tsc 0 错;lint 0 error / 118 warning(未增);3350→3355 条,0 条既有
断言被改,31 条无 Docker 失败与基线逐条一致;四个路由渲染标记不变,
/ 首屏 gzip −0.58%。

「20 张盘以上不重叠、滚动不卡」与「挂外壳后的打印真实输出」无真机做
不了,已写成环境缺口,清单在 docs/testing/cend-report-20260916.md。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-16 06:41:39 +00:00

509 lines
25 KiB
TypeScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { PersonalReportDocumentView } from "../src/components/personal-report/personal-report-document-view.tsx";
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, ReportDocument, ReportDocumentV2 } from "../src/lib/personal-report-contract.ts";
Object.assign(globalThis, { React });
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;
function render(document: ReportDocument): string {
return renderToStaticMarkup(React.createElement(PersonalReportDocumentView, { document }));
}
function withPresentationMode(document: LegacyReportDocumentV1, presentationMode: "default" | "research"): LegacyReportDocumentV1 {
return { ...structuredClone(document), presentationMode };
}
function withCharts(document: LegacyReportDocumentV1, charts: LegacyReportDocumentV1["charts"]): LegacyReportDocumentV1 {
return { ...structuredClone(document), charts };
}
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("renders the selected answer-first order: cover -> summary -> D1 -> themes -> appendix -> disclaimer", () => {
const markup = render(canonicalFixture);
const positions: Record<string, number> = {
subject: markup.indexOf(canonicalFixture.subject.displayName),
d1: markup.indexOf('id="report-charts"'),
summary: markup.indexOf('id="report-summary"'),
theme: markup.indexOf('id="report-themes"'),
appendix: markup.indexOf('id="report-appendix"'),
disclaimer: markup.indexOf('id="report-disclaimer"'),
};
for (const key of Object.keys(positions)) {
assert.ok(positions[key] >= 0, `${key} must be rendered`);
}
assert.ok(positions.subject < positions.summary, "cover comes before the summary");
assert.ok(positions.summary < positions.d1, "answer-first summary comes before the D1 chart");
assert.ok(positions.d1 < positions.theme, "D1 chart comes before themes");
assert.ok(positions.theme < positions.appendix, "themes come before the appendix");
assert.ok(positions.appendix < positions.disclaimer, "appendix comes before the disclaimer");
});
test("accepted and confirmed birth times render as distinct report metadata states", () => {
const accepted = structuredClone(canonicalFixture);
accepted.subject.birthTimeStatus = "accepted";
const acceptedMarkup = render(accepted);
assert.match(acceptedMarkup, /<dt>生时状态<\/dt><dd>已采用时间(未确认)<\/dd>/);
const confirmed = structuredClone(canonicalFixture);
confirmed.subject.birthTimeStatus = "confirmed";
const confirmedMarkup = render(confirmed);
assert.match(confirmedMarkup, /<dt>生时状态<\/dt><dd>已确认时间<\/dd>/);
});
test("v2 renders scope, natal foundation, themes, current phase, actions, blocked disclosures and provenance", () => {
const markup = render(canonicalV2Fixture);
const positions: Record<string, number> = {
summary: markup.indexOf('id="report-summary"'),
natal: markup.indexOf('id="report-natal-foundation"'),
d1: markup.indexOf('id="report-charts"'),
themes: markup.indexOf('id="report-themes"'),
currentPhase: markup.indexOf('id="report-current-phase"'),
actions: markup.indexOf('id="report-action-notes"'),
blocked: markup.indexOf('id="report-blocked-disclosures"'),
appendix: markup.indexOf('id="report-appendix"'),
disclaimer: markup.indexOf('id="report-disclaimer"'),
};
for (const [key, position] of Object.entries(positions)) {
assert.ok(position >= 0, `${key} must be rendered for v2`);
}
assert.ok(positions.summary < positions.natal);
assert.ok(positions.natal < positions.d1);
assert.ok(positions.d1 < positions.themes);
assert.ok(positions.themes < positions.currentPhase);
assert.ok(positions.currentPhase < positions.actions);
assert.ok(positions.actions < positions.blocked);
assert.ok(positions.blocked < positions.appendix);
assert.ok(positions.appendix < positions.disclaimer);
assert.match(markup, /data-report-schema-version="report_document\.v2"/);
assert.match(markup, /报告深度<\/dt><dd>标准/);
assert.match(markup, /请求主题<\/dt><dd>综合、事业、财富、应期/);
assert.match(markup, new RegExp(canonicalV2Fixture.natalFoundation.keyFactors[0]));
assert.match(markup, new RegExp(canonicalV2Fixture.currentPhase?.phaseLabel ?? "missing"));
assert.match(markup, new RegExp(canonicalV2Fixture.actionNotes[0].title));
assert.match(markup, new RegExp(canonicalV2Fixture.blockedConflictDisclosure[0].missingEvidence[0]));
assert.match(markup, new RegExp(canonicalV2Fixture.provenance.skillName));
assert.match(markup, new RegExp(canonicalV2Fixture.provenance.skillVersion.replaceAll(".", "\\.")));
assert.match(markup, new RegExp(canonicalV2Fixture.provenance.evidenceHash));
assert.match(markup, new RegExp(canonicalV2Fixture.disclaimer.slice(0, 20)));
});
test("v2 currentPhase is omitted when null, without hiding the remaining v2 sections", () => {
const document = structuredClone(canonicalV2Fixture);
document.currentPhase = null;
const markup = render(document);
assert.doesNotMatch(markup, /id="report-current-phase"/);
assert.match(markup, /id="report-action-notes"/);
assert.match(markup, /id="report-blocked-disclosures"/);
});
test("v2 chart evidence refs link to the appendix and every real structured chart uses the SVG renderer", () => {
const document = structuredClone(canonicalV2Fixture);
document.charts.push({
...structuredClone(document.charts[0]),
id: "D2",
title: "D2 财富分盘",
});
const markup = render(document);
assert.equal((markup.match(/<svg/g) ?? []).length, 2);
assert.match(markup, /D2 财富分盘/);
for (const evidenceRef of document.charts.flatMap((chart) => chart.evidenceRefs)) {
assert.match(markup, new RegExp(`href="#evidence-${evidenceRef}"`));
}
});
test("v2 model-authored text stays escaped and cannot inject HTML, CSS or SVG", () => {
const document = structuredClone(canonicalV2Fixture);
document.actionNotes[0].note = '<style>body{display:none}</style><svg onload="alert(1)"></svg>';
const markup = render(document);
assert.match(markup, /&lt;style&gt;body\{display:none\}&lt;\/style&gt;/);
assert.match(markup, /&lt;svg onload=&quot;alert\(1\)&quot;&gt;&lt;\/svg&gt;/);
assert.doesNotMatch(markup, /<style>body\{display:none\}<\/style>/);
assert.doesNotMatch(markup, /<svg onload=/);
});
test("技法审计表不会出现在核心判断之前", () => {
const markup = render(canonicalFixture);
const summaryAt = markup.indexOf("核心判断");
const auditAt = markup.indexOf("技法审计表");
assert.ok(summaryAt >= 0 && auditAt >= 0, "both summary and audit table render");
assert.ok(auditAt > summaryAt, "audit table must appear after the summary");
});
test("evidence appendix is collapsed by default in default mode and expanded in research mode", () => {
const auditText = "MEVG / Global Web Evidence";
const collapsed = render(canonicalFixture);
assert.match(collapsed, /<details class="personal-report-appendix-details">/, "default mode starts collapsed");
assert.match(collapsed, /<summary[^>]*>展开 \/ 收起附录<\/summary>/, "native disclosure remains usable without hydration");
assert.ok(collapsed.indexOf(auditText) >= 0, "content exists in the closed details element");
const expanded = render(withPresentationMode(canonicalFixture, "research"));
assert.match(expanded, /<details class="personal-report-appendix-details" open="">/, "research mode starts expanded");
assert.ok(expanded.indexOf(auditText) >= 0, "research mode renders appendix content");
});
test("expandedByDefault=true expands the appendix even in default mode", () => {
const document = structuredClone(canonicalFixture);
document.evidenceAppendix.expandedByDefault = true;
const markup = render(document);
assert.match(markup, /<details class="personal-report-appendix-details" open="">/, "expandedByDefault forces the expanded state");
});
test("birth-time sensitivity alone keeps the appendix visible and shows minute changes", () => {
const document = structuredClone(canonicalV2Fixture);
document.evidenceAppendix = {
expandedByDefault: false,
techniqueAudit: [],
conflicts: [],
calculationEvidence: [],
blockedTechniques: [],
birthTimeSensitivity: {
window: { startTime: "10:00", endTime: "10:01", representativeTime: "10:00", candidateCount: 2 },
themes: [{
theme: "career",
status: "sensitive",
stableLayers: [],
sensitiveLayers: ["D10.ascendant"],
minuteVariations: [{
layer: "D10.ascendant",
values: [{ minute: "10:00", value: "Leo" }, { minute: "10:01", value: "Virgo" }],
}],
}],
claimBoundary: "Candidate-window comparison only.",
},
};
const markup = render(document);
assert.match(markup, /personal-report-appendix-details/);
assert.match(markup, /D10\.ascendant/);
assert.match(markup, /10:00=Leo/);
assert.match(markup, /10:01=Virgo/);
});
test("D1 SVG renders when real houses exist; D9/D10 are never fabricated", () => {
const svgCount = (markup: string) => (markup.match(/<svg/g) ?? []).length;
// Fixture has only a real D1 -> exactly one SVG.
const fixtureMarkup = render(canonicalFixture);
assert.equal(svgCount(fixtureMarkup), 1);
assert.match(fixtureMarkup, /D1/);
// D9 with real houses -> rendered as a second SVG.
const withD9 = withCharts(canonicalFixture, [
...canonicalFixture.charts,
{
id: "D9",
title: "D9 九分盘",
houses: [{ houseNumber: 1, sign: "白羊座", occupants: ["月亮"] }],
planets: [{ name: "月亮", sign: "白羊座", longitudeDegrees: 1.5, houseNumber: 1, retrograde: false }],
claimStatus: "single_system_inference",
},
]);
assert.equal(svgCount(render(withD9)), 2);
// D9 with empty houses must NOT produce a placeholder chart.
const withEmptyD9 = withCharts(canonicalFixture, [
...canonicalFixture.charts,
{ id: "D9", title: "D9 九分盘", houses: [], claimStatus: "blocked" },
]);
assert.equal(svgCount(render(withEmptyD9)), 1);
// No charts at all -> no SVG, no fabricated chart section.
const noCharts = render(withCharts(canonicalFixture, []));
assert.equal(svgCount(noCharts), 0);
assert.doesNotMatch(noCharts, /personal-report-chart-figure/);
});
test("each house owns its occupant coordinates and dense houses stay clipped and bounded", () => {
const dense = structuredClone(canonicalFixture);
dense.charts[0].houses[0].occupants = Array.from({ length: 12 }, (_, index) => `占星体${index + 1}`);
const markup = render(dense);
// 原值 / 新值 / 原因
// transform="translate(0 100)" / 1 宫多边形含 200,0 / 北印式是菱形宫,不再用 4×4 方格偏移
// transform="translate(300 0)" / 9 宫多边形含 400,400 / 同上
// +5 项 / +7 / 宫内最多 6 行(含折叠行),12 个占星体变成 5 行加 +7
assert.match(markup, /points="200,0 300,100 200,200 100,100"/, "house 1 is the top diamond");
assert.match(markup, /points="400,400 300,300 400,200"/, "house 9 is the lower-right triangle");
assert.match(markup, /clip-path="url\(#.*-house-1\)"/, "house text is clipped to its own polygon");
assert.match(markup, />\+7</, "dense houses summarize overflow instead of painting twelve overlapping lines");
});
test("evidenceRefs only produce in-page anchors for ids present in the appendix", () => {
const markup = render(canonicalFixture);
const knownRef = canonicalFixture.thematicNarrative[0].evidenceRefs.find(
(ref) => canonicalFixture.evidenceAppendix.calculationEvidence.some((row) => row.id === ref),
);
assert.ok(knownRef, "fixture should contain a resolvable evidenceRef");
assert.match(markup, new RegExp(`href="#evidence-${knownRef}"`));
// No arbitrary URL schemes anywhere.
assert.doesNotMatch(markup, /href="(https?:|javascript:|vbscript:|data:|file:)/i);
});
test("no dangerous HTML: no script tags, no external resources, no dangerouslySetInnerHTML", async () => {
const markup = render(canonicalFixture);
assert.doesNotMatch(markup, /<script/i);
assert.doesNotMatch(markup, /\ssrc=/i);
assert.doesNotMatch(markup, /style\s*=/i);
const componentDir = new URL("../src/components/personal-report/", import.meta.url);
const { readdirSync } = await import("node:fs");
for (const file of readdirSync(componentDir)) {
if (!file.endsWith(".tsx") && !file.endsWith(".ts")) continue;
const source = readFileSync(new URL(file, componentDir), "utf8");
assert.doesNotMatch(source, /dangerouslySetInnerHTML/, `${file} must not use dangerouslySetInnerHTML`);
}
});
test("long theme sections are not forced to avoid page breaks; only small elements are", () => {
const markup = render(canonicalFixture);
const themeTag = markup.match(/class="personal-report-theme[^"]*"/);
assert.ok(themeTag, "theme articles carry the personal-report-theme class");
assert.doesNotMatch(themeTag[0], /personal-report-avoid-break/, "themes must be allowed to paginate");
const globals = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const printBlock = globals.slice(globals.indexOf("personal-report: unique block"));
assert.doesNotMatch(printBlock, /@page\s*\{/, "report page size must not leak into global print CSS");
const pageSource = readFileSync(
new URL("../src/components/personal-report/personal-report-page.tsx", import.meta.url),
"utf8",
);
assert.match(pageSource, /<style media="print">\{"@page \{ size: A4; margin: 13mm 12mm 14mm; \}"\}<\/style>/);
assert.match(printBlock, /@media print/);
assert.match(printBlock, /\.personal-report-screen-only\s*\{[\s\S]*?display:\s*none\s*!important/);
assert.match(printBlock, /print-color-adjust:\s*exact\s*!important/);
assert.match(printBlock, /\.personal-report-avoid-break\s*\{[\s\S]*?break-inside:\s*avoid-page/);
assert.match(printBlock, /\.personal-report-avoid-break-row\s*\{[\s\S]*?break-inside:\s*avoid/);
assert.match(printBlock, /\.personal-report-print-always\s*\{[\s\S]*?display:\s*block\s*!important/);
assert.match(printBlock, /\.personal-report-reader\s*\{[\s\S]*?height:\s*100%[\s\S]*?overflow-y:\s*auto/);
assert.match(printBlock, /\.personal-report-reader\s*\{[\s\S]*?-webkit-overflow-scrolling:\s*touch/);
assert.match(printBlock, /html, body\s*\{[^}]*height:\s*auto\s*!important[^}]*overflow:\s*visible\s*!important/);
assert.match(printBlock, /\.personal-report-table-wrap\s*\{[^}]*overflow:\s*visible\s*!important/);
assert.match(printBlock, /table-layout:\s*fixed\s*!important/);
});
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 the browser print/PDF action with capability and hydration guards", () => {
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, /PERSONAL_REPORT_EXPORT_LABEL/);
assert.match(actionsSource, /PERSONAL_REPORT_PRINT_LABEL/);
assert.match(actionsSource, /downloadPersonalReportLongformAppendix/);
assert.match(actionsSource, /printPersonalReport/);
assert.match(actionsSource, /Printer/);
assert.match(actionsSource, /useSyncExternalStore\(subscribePrintCapability, isPrintSupported, \(\) => false\)/);
assert.match(actionsSource, /disabled=\{printBusy \|\| exportBusy \|\| !printSupported\}/);
});
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",
);
assert.match(pageSource, /import \{ SecondaryShell \} from "@\/components\/secondary-shell";/);
// 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(/<SecondaryShell 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).
assert.match(viewSource, /<ReportToc headings=\{outline\.headings\} \/>/);
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;/);
});