fix(report): draw North Indian charts from fences after skipHtml dropped SVG
Longform report pages kept the D1/D9/Moon and varga headings but skipHtml stripped the engine's inline SVG. Emit a jyotish-chart JSON fence beside each SVG and render a diamond chart on the reader without relaxing HTML sanitization. BUG-607. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+1457
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
import assert from "node:assert/strict";
|
||||
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 MARKDOWN = [
|
||||
"#### D1 — Rashi Chart(本命盘)",
|
||||
"",
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 420 480"><text>engine</text></svg>',
|
||||
"",
|
||||
"```jyotish-chart",
|
||||
JSON.stringify(SAMPLE_BLOCK),
|
||||
"```",
|
||||
"",
|
||||
"```jyotish-chart",
|
||||
"{not-json",
|
||||
"```",
|
||||
].join("\n");
|
||||
|
||||
test("markdown view draws the North Indian component and skips engine SVG HTML", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
React.createElement(PersonalReportMarkdownView, { markdown: MARKDOWN }),
|
||||
);
|
||||
assert.match(markup, /<svg/);
|
||||
assert.match(markup, /role="img"/);
|
||||
assert.match(markup, /personal-report-chart-figure/);
|
||||
assert.doesNotMatch(markup, /viewBox="0 0 420 480"/);
|
||||
assert.match(markup, /图盘数据无效/);
|
||||
assert.doesNotMatch(markup, /<script/i);
|
||||
assert.match(markup, new RegExp(planetDisplayLabel("Saturn", 3.4, true)));
|
||||
});
|
||||
@@ -243,10 +243,14 @@ test("each house owns its occupant coordinates and dense houses stay clipped and
|
||||
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");
|
||||
// 原值 / 新值 / 原因
|
||||
// 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", () => {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
CHART_SIZE,
|
||||
HOUSE_NUMBERS,
|
||||
HOUSE_POLYGONS,
|
||||
pointInPolygon,
|
||||
polygonArea,
|
||||
} from "../src/lib/north-indian-chart-geometry.ts";
|
||||
import {
|
||||
parseReportChartBlock,
|
||||
planetDisplayLabel,
|
||||
stripReportChartBlocks,
|
||||
toNorthIndianChart,
|
||||
type ReportChartBlock,
|
||||
} from "../src/lib/report-chart-block.ts";
|
||||
|
||||
const fixture = JSON.parse(
|
||||
readFileSync(new URL("./fixtures/report-chart-blocks-golden.json", import.meta.url), "utf8"),
|
||||
) as {
|
||||
sourceCommand: string;
|
||||
blocks: ReportChartBlock[];
|
||||
};
|
||||
|
||||
function wrapMarkdown(blocks: readonly ReportChartBlock[]): string {
|
||||
return blocks.map((block) => [
|
||||
`#### ${block.title}`,
|
||||
"",
|
||||
`<svg viewBox="0 0 420 480"></svg>`,
|
||||
"",
|
||||
"```jyotish-chart",
|
||||
JSON.stringify(block),
|
||||
"```",
|
||||
"",
|
||||
].join("\n")).join("\n");
|
||||
}
|
||||
|
||||
test("golden fixture records the engine command that produced it", () => {
|
||||
assert.match(fixture.sourceCommand, /test_report_chart_block|build_professional_report_reference_packet|render_pl9_markdown/);
|
||||
});
|
||||
|
||||
test("all 22 golden blocks parse", () => {
|
||||
assert.equal(fixture.blocks.length, 22);
|
||||
for (const raw of fixture.blocks) {
|
||||
const parsed = parseReportChartBlock(JSON.stringify(raw));
|
||||
assert.ok(parsed, `block ${raw.id} should parse`);
|
||||
}
|
||||
});
|
||||
|
||||
test("D1 whole-sign houses start at the ascendant and wrap at 12", () => {
|
||||
const d1 = fixture.blocks.find((block) => block.id === "D1");
|
||||
assert.ok(d1);
|
||||
const chart = toNorthIndianChart(d1);
|
||||
assert.equal(chart.houses[0]?.sign, d1.ascendant.sign);
|
||||
const signs = [
|
||||
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
|
||||
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
|
||||
] as const;
|
||||
const ascIndex = signs.indexOf(d1.ascendant.sign);
|
||||
assert.equal(chart.houses[11]?.sign, signs[(ascIndex + 11) % 12]);
|
||||
});
|
||||
|
||||
test("retrograde labels include 逆 after the planet letter", () => {
|
||||
const sample = fixture.blocks.flatMap((block) => block.planets).find((planet) => planet.retrograde);
|
||||
assert.ok(sample, "golden chart should contain at least one retrograde planet");
|
||||
const label = planetDisplayLabel(sample.name, sample.degree, true);
|
||||
assert.match(label, /逆 \d+°$/);
|
||||
});
|
||||
|
||||
test("malicious title is not drawn; parse still succeeds", () => {
|
||||
const d1 = fixture.blocks.find((block) => block.id === "D1");
|
||||
assert.ok(d1);
|
||||
const poisoned = parseReportChartBlock(JSON.stringify({
|
||||
...d1,
|
||||
title: '<img onerror="alert(1)" src=x>',
|
||||
}));
|
||||
assert.ok(poisoned);
|
||||
const chart = toNorthIndianChart(poisoned);
|
||||
const serialized = JSON.stringify(chart);
|
||||
assert.doesNotMatch(serialized, /<img/);
|
||||
assert.doesNotMatch(serialized, /onerror/);
|
||||
});
|
||||
|
||||
test("invalid payloads return null", () => {
|
||||
assert.equal(parseReportChartBlock("{"), null);
|
||||
assert.equal(parseReportChartBlock(JSON.stringify({ ...fixture.blocks[0], extra: true })), null);
|
||||
assert.equal(parseReportChartBlock(JSON.stringify({ ...fixture.blocks[0], ascendant: { ...fixture.blocks[0].ascendant, degree: 31 } })), null);
|
||||
assert.equal(parseReportChartBlock(JSON.stringify({
|
||||
...fixture.blocks[0],
|
||||
planets: [{ name: "Uranus", sign: "Aries", degree: 1, retrograde: false }],
|
||||
})), null);
|
||||
});
|
||||
|
||||
test("stripReportChartBlocks removes fences and keeps engine SVGs", () => {
|
||||
const markdown = wrapMarkdown(fixture.blocks);
|
||||
const stripped = stripReportChartBlocks(markdown);
|
||||
assert.doesNotMatch(stripped, /```jyotish-chart/);
|
||||
assert.equal((stripped.match(/<svg/g) ?? []).length, (markdown.match(/<svg/g) ?? []).length);
|
||||
});
|
||||
|
||||
test("house polygons fill the square without overlapping interiors", () => {
|
||||
const total = HOUSE_NUMBERS.reduce((sum, houseNumber) => sum + polygonArea(HOUSE_POLYGONS[houseNumber]), 0);
|
||||
assert.ok(Math.abs(total - CHART_SIZE * CHART_SIZE) < 1e-6, `area ${total}`);
|
||||
const step = 5;
|
||||
for (let x = step; x < CHART_SIZE; x += step) {
|
||||
for (let y = step; y < CHART_SIZE; y += step) {
|
||||
const hits = HOUSE_NUMBERS.filter((houseNumber) => pointInPolygon({ x, y }, HOUSE_POLYGONS[houseNumber]));
|
||||
assert.ok(hits.length <= 1, `point ${x},${y} in ${hits.join(",")}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user