Files
Jyotisha/frontend/tests/personal-report-view.test.ts
T
jesse-ux e4e73f56c0
Independent Staging Quality Gate / publish (push) Canceled after 0s
Independent Staging Quality Gate / validate (push) Canceled after 9m33s
fix(web): 四个页面共用一份会话列表,空会话不入列
对话、星盘、星历、报告进同一 (app) 外壳,列表只拉一次。服务端不再列出空咨询;新建复用已有空会话。新标题改成「生时校正 · M月D日」,侧栏副标题用创建时间。
2026-09-17 21:27:40 +08: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/(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).
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;/);
});