f4a2ba86ae
Report pages now scroll inside the chat shell lock, and admin product save forks a draft or retires a published plan instead of rejecting with a generic constraint error. Co-authored-by: Cursor <cursoragent@cursor.com>
364 lines
18 KiB
TypeScript
364 lines
18 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 { 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, /<style>body\{display:none\}<\/style>/);
|
|
assert.match(markup, /<svg onload="alert\(1\)"><\/svg>/);
|
|
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("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);
|
|
assert.match(markup, /transform="translate\(0 100\)"/, "house 1 uses its own cell offset");
|
|
assert.match(markup, /transform="translate\(300 0\)"/, "house 9 uses its own cell offset");
|
|
assert.match(markup, /clip-path="url\(#.*-house-1\)"/, "house text is clipped to its own cell");
|
|
assert.match(markup, /\+5 项/, "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"),
|
|
reportDocument: canonicalFixture,
|
|
});
|
|
assert.equal(ready.phase, "ready");
|
|
assert.ok(ready.phase === "ready" && ready.document.reportId === canonicalFixture.reportId);
|
|
|
|
const readyV2 = classifyReportEnvelope(200, {
|
|
report: view("ready"),
|
|
reportDocument: canonicalV2Fixture,
|
|
});
|
|
assert.equal(readyV2.phase, "ready");
|
|
assert.ok(readyV2.phase === "ready" && readyV2.document.schemaVersion === "report_document.v2");
|
|
|
|
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") }), { 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, "invalid");
|
|
|
|
const badDocument = classifyReportEnvelope(200, {
|
|
report: view("ready"),
|
|
reportDocument: { schemaVersion: "wrong" },
|
|
});
|
|
assert.equal(badDocument.phase, "invalid");
|
|
|
|
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("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, /打印 \/ 保存为 PDF/);
|
|
assert.match(actionsSource, /printPersonalReport/);
|
|
assert.match(actionsSource, /Printer/);
|
|
assert.match(actionsSource, /useSyncExternalStore\(subscribePrintCapability, isPrintSupported, \(\) => false\)/);
|
|
assert.match(actionsSource, /disabled=\{busy \|\| !printSupported\}/);
|
|
});
|