Files
Jyotisha/frontend/tests/personal-report-view.test.ts
T
Jesse_ChenandClaude Fable 5.1 fb77c86585 test(ui): 侧栏只读模式、共享外壳与列表缓存的合同回归
新增 8 条:`sidebar-data-cache.test.ts`(命中不重拉 / 过期重拉一次 / 写操作后
拿到新标题并逐条锁住五个写路径 / 双账户不串 / 只存内存 / 401 清空)、
`sidebar-state.test.ts` +2(收起后重挂仍收起,含三种降级;移动端不读不写)、
`sidebar-contract.test.ts` +1(只读模式只少三样)、`chart-page-view.test.tsx` +1
(`(secondary)` layout 恰好挂一份只读侧栏,数据 hook 无写方法)。

改写 9 处既有断言,每处带「原值 / 新值 / 原因」三栏注释,均未削弱:
`window.location.assign(path)` → `<SidebarMenuLink href>` 并追加反向断言;
`onOpenReports` / `useRouter` 改成 doesNotMatch;`SecondaryShell` → `SecondaryHeader`;
导航顺序改在 `NAV_PAGES` 常量里量;两个 render 辅助改为裹 `SidebarProvider`
(provider 上移到 layout);三处源码路径跟随路由组移动。

测试总数 3391 → 3399,失败清单与基线逐条一致(47 条均为无 Docker 的既有缺口)。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-16 11:23:11 +00:00

515 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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",
);
// 原值:`SecondaryShell` 的 import 与 `<SecondaryShell title="个人报告">` 的出现次数
// 新值:`SecondaryHeader` 的 import 与 `<SecondaryHeader title="个人报告" />` 的出现次数
// 原因:TASK-sidebar-unify D2 把 provider + 侧栏 + inset 上移到
// `app/(secondary)/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).
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;/);
});