import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; import test from "node:test"; import { PersonalReportMarkdownView } from "../src/components/personal-report/personal-report-markdown-view.tsx"; import { planetDisplayLabel, type ReportChartBlock } from "../src/lib/report-chart-block.ts"; Object.assign(globalThis, { React }); const SAMPLE_BLOCK: ReportChartBlock = { version: 1, id: "D1", title: "D1 — Rashi Chart(本命盘)", layout: "north", ascendant: { sign: "Leo", degree: 12.34 }, planets: [ { name: "Sun", sign: "Leo", degree: 3.21, retrograde: false }, { name: "Saturn", sign: "Capricorn", degree: 3.4, retrograde: true }, ], }; const D9_BLOCK: ReportChartBlock = { ...SAMPLE_BLOCK, id: "D9", title: "D9 — Navamsa(婚盘)" }; const MOON_BLOCK: ReportChartBlock = { ...SAMPLE_BLOCK, id: "MOON", title: "Moon Chart(月亮参考盘)" }; const MARKDOWN = [ "#### D1 — Rashi Chart(本命盘)", "", 'engine', "", "```jyotish-chart", JSON.stringify(SAMPLE_BLOCK), "```", "", "```jyotish-chart", "{not-json", "```", ].join("\n"); const GOLDEN = JSON.parse( readFileSync(new URL("./fixtures/report-chart-blocks-golden.json", import.meta.url), "utf8"), ) as { blocks: ReportChartBlock[] }; function fence(block: ReportChartBlock): string { return ["```jyotish-chart", JSON.stringify(block), "```"].join("\n"); } function countClass(markup: string, className: string): number { const escaped = className.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return (markup.match(new RegExp(`(?:class|className)="[^"]*\\b${escaped}\\b`, "g")) ?? []).length; } function cardInnerHtml(markup: string): string[] { const cards: string[] = []; const re = /
([\s\S]*?)<\/div>/g; let match: RegExpExecArray | null; while ((match = re.exec(markup))) cards.push(match[1] ?? ""); return cards; } test("markdown view draws the North Indian component and skips engine SVG HTML", () => { const markup = renderToStaticMarkup( React.createElement(PersonalReportMarkdownView, { markdown: MARKDOWN }), ); assert.match(markup, / { const markdown = [ "#### D1 — Rashi Chart(本命盘)", "", fence(SAMPLE_BLOCK), "", "#### D9 — Navamsa(婚盘)", "", fence(D9_BLOCK), "", "#### Moon Chart(月亮参考盘)", "", "月亮盘画在本命月上,用来对照夜间事件。", "", fence(MOON_BLOCK), "", "#### Shodashvarga 星座总表", "", "| 分盘 | 星座 |", "| --- | --- |", "| D1 | Leo |", ].join("\n"); const markup = renderToStaticMarkup( React.createElement(PersonalReportMarkdownView, { markdown }), ); assert.equal(countClass(markup, "personal-report-chart-grid"), 1); assert.equal(countClass(markup, "is-single"), 0); assert.equal(countClass(markup, "personal-report-chart-card"), 3); const cards = cardInnerHtml(markup); assert.equal(cards.length, 3); assert.match(cards[0] ?? "", /^

[\s\S]*<\/h4>
/); assert.match(cards[1] ?? "", /^

[\s\S]*<\/h4>
/); assert.match( cards[2] ?? "", /^

[\s\S]*<\/h4>

月亮盘画在本命月上,用来对照夜间事件。<\/p>

/, ); const withoutCards = markup.replace(/
[\s\S]*?<\/div>/g, ""); assert.match(withoutCards, /

Shodashvarga 星座总表<\/h4>/); assert.doesNotMatch(withoutCards, /personal-report-chart-card/); }); test("a single chart pair is wrapped in an is-single grid", () => { const markdown = ["#### D1 — Rashi Chart(本命盘)", "", fence(SAMPLE_BLOCK)].join("\n"); const markup = renderToStaticMarkup( React.createElement(PersonalReportMarkdownView, { markdown }), ); assert.equal(countClass(markup, "personal-report-chart-grid"), 1); assert.equal(countClass(markup, "is-single"), 1); assert.equal(countClass(markup, "personal-report-chart-card"), 1); }); test("golden 22 chart figures all sit inside chart cards", () => { const markdown = GOLDEN.blocks.map((block) => [ `#### ${block.title}`, "", ``, "", fence(block), "", ].join("\n")).join("\n"); const markup = renderToStaticMarkup( React.createElement(PersonalReportMarkdownView, { markdown }), ); assert.equal(countClass(markup, "personal-report-chart-figure"), 22); assert.equal(countClass(markup, "personal-report-chart-card"), 22); const leftover = markup.replace(/
[\s\S]*?<\/div>/g, ""); assert.doesNotMatch(leftover, /personal-report-chart-figure/); }); test("chart layout CSS does not float personal-report-chart boxes", () => { const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8") .replace(/\/\*[\s\S]*?\*\//g, ""); for (const chunk of css.split("}")) { if (!chunk.includes("personal-report-chart")) continue; assert.doesNotMatch(chunk, /float\s*:/); assert.doesNotMatch(chunk, /margin-left\s*:\s*-50%/); } }); test("article markdown trees are memoized and hold no scroll state", () => { const src = readFileSync( new URL("../src/components/personal-report/personal-report-markdown-view.tsx", import.meta.url), "utf8", ); const viewStart = src.indexOf("export function PersonalReportMarkdownView"); const viewEnd = src.indexOf("\nfunction TocList", viewStart); assert.ok(viewStart >= 0 && viewEnd > viewStart); const view = src.slice(viewStart, viewEnd); assert.doesNotMatch(view, /\buseState\b/); const lines = src.split("\n"); lines.forEach((line, index) => { if (!line.includes("renderMarkdown(")) return; if (line.includes("function renderMarkdown")) return; const window = lines.slice(Math.max(0, index - 6), index + 1).join("\n"); assert.match(window, /useMemo\s*\(/, `renderMarkdown at line ${index + 1} must be inside useMemo`); }); });