Files
Jyotisha/frontend/tests/report-chart-block.test.ts
T
Jesse_ChenandCursor 2fdcb14fd6 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>
2026-09-09 15:11:00 +08:00

114 lines
4.1 KiB
TypeScript

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(",")}`);
}
}
});