Files
Jyotisha/frontend/tests/personal-report-view.test.ts
T

240 lines
12 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 { ReportDocumentV1 } 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 canonicalFixture = JSON.parse(readFileSync(fixturePath, "utf8")) as ReportDocumentV1;
function render(document: ReportDocumentV1): string {
return renderToStaticMarkup(React.createElement(PersonalReportDocumentView, { document }));
}
function withPresentationMode(document: ReportDocumentV1, presentationMode: "default" | "research"): ReportDocumentV1 {
return { ...structuredClone(document), presentationMode };
}
function withCharts(document: ReportDocumentV1, charts: ReportDocumentV1["charts"]): ReportDocumentV1 {
return { ...structuredClone(document), charts };
}
test("canonical fixture passes the canonical contract parse", () => {
const parsed = safeParseReportDocument(canonicalFixture);
assert.equal(parsed.ok, true);
if (parsed.ok) {
assert.equal(parsed.document.reportId, canonicalFixture.reportId);
}
});
test("renders sections in the fixed order: cover -> D1 -> summary -> themes -> appendix -> disclaimer", () => {
const markup = render(canonicalFixture);
const positions: Record<string, number> = {
subject: markup.indexOf(canonicalFixture.subject.displayName),
d1: markup.indexOf(canonicalFixture.charts[0].title),
summary: markup.indexOf("核心摘要"),
theme: markup.indexOf(canonicalFixture.thematicNarrative[0].title),
appendix: markup.indexOf("证据附录"),
disclaimer: markup.indexOf("声明"),
};
for (const key of Object.keys(positions)) {
assert.ok(positions[key] >= 0, `${key} must be rendered`);
}
assert.ok(positions.subject < positions.d1, "cover comes before D1 chart");
assert.ok(positions.d1 < positions.summary, "D1 chart comes before the summary");
assert.ok(positions.summary < positions.theme, "summary 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("Technique Audit Table is never placed before the summary", () => {
const markup = render(canonicalFixture);
const summaryAt = markup.indexOf("核心摘要");
const auditAt = markup.indexOf("Technique Audit Table");
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, /personal-report-print-always hidden/, "default mode starts collapsed");
assert.doesNotMatch(collapsed, /personal-report-print-always block/, "default mode must not render expanded");
assert.ok(collapsed.indexOf(auditText) >= 0, "content exists in the DOM but is visually hidden");
const expanded = render(withPresentationMode(canonicalFixture, "research"));
assert.match(expanded, /personal-report-print-always block/, "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, /personal-report-print-always block/, "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.equal(noCharts.indexOf("命盘与基础信息"), -1);
});
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.match(printBlock, /@page\s*\{[^}]*size:\s*A4/);
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/);
});
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);
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<ReportDocumentV1>).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 ReportDocumentV1;
badRefs.thematicNarrative[0].evidenceRefs = ["ev-does-not-exist"];
assert.equal(safeParseReportDocument(badRefs).ok, false, "dangling evidenceRefs must fail guards");
});
test("print button is gated on ready state (source contract)", () => {
const actionsSource = readFileSync(
new URL("../src/components/personal-report/report-actions.tsx", import.meta.url),
"utf8",
);
assert.match(actionsSource, /const disabled = !ready \|\| busy \|\| !isPrintSupported\(\)/);
assert.match(actionsSource, /disabled=\{disabled\}/);
assert.match(actionsSource, /printPersonalReport/);
assert.match(actionsSource, /personal-report-screen-only/);
});