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 { ChartVedicTab } from "../src/components/chart-page/chart-vedic-tab.tsx"; import { VedicChartSvg } from "../src/components/personal-report/vedic-chart-svg.tsx"; import { assembleChartView } from "../src/lib/chart-view-load.ts"; import { chartViewOkSchema, type ChartViewOk } from "../src/lib/chart-view-contract.ts"; import { chartViewEngineCacheKey, chartViewEngineCachePath } from "../src/lib/chart-view-engine.ts"; import { CHART_VIEW_COPY, SIGN_ORDER } from "../src/lib/chart-view-labels.ts"; Object.assign(globalThis, { React }); // Real engine output only: the chart-page golden and the ephemeris page's // `/api/chart` packet for 2026-09-15 noon (same engine route, same Raman mean). const golden = JSON.parse(readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8")); const transitPacket = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-chart-transit-2026-09-15.golden.json", import.meta.url), "utf8")); const serviceSource = readFileSync(new URL("../src/lib/chart-view-service.ts", import.meta.url), "utf8"); const profile = { name: "示例", date: "1990-06-15", time: "12:00", placeLabel: "北京", latitude: 39.9042, longitude: 116.4074, timezoneOffset: 8, timezoneId: "Asia/Shanghai", ayanamsa: "raman", birthTimeStatus: "confirmed", }; type Call = { path: string; body: Record; cacheScope?: string }; async function assemble(layers: ("transit" | "varga")[]): Promise<{ view: ChartViewOk; calls: Call[] }> { const calls: Call[] = []; const result = await assembleChartView({ userId: "user-1", profile, asOf: "2026-09-15", layers, postEngine: async (path, body, cacheScope) => { calls.push({ path, body, cacheScope }); if (path === "/api/chart" && cacheScope?.startsWith("transit:")) return { status: "ok", payload: transitPacket }; if (path === "/api/chart") return { status: "ok", payload: golden.chart }; if (path === "/api/varga_full") return { status: "ok", payload: golden.varga_full }; return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 }; }, }); assert.equal(result.body.status, "ok"); return { view: chartViewOkSchema.parse(result.body), calls }; } function signIndex(sign: string): number { return (SIGN_ORDER as readonly string[]).indexOf(sign); } /** House whose clip group holds the lagna mark. */ function ascendantMarkHouses(svg: string): number[] { return svg.split('clip-path="url(#').slice(1).flatMap((chunk) => { const house = Number(chunk.match(/-house-(\d+)\)/)?.[1]); const body = chunk.split("")[0] ?? ""; return body.includes("personal-report-chart-asc") ? [house] : []; }); } test("the Moon chart is the D1 counted from the Moon's sign, projected without a request", async () => { const { view, calls } = await assemble([]); assert.deepEqual(calls.map((call) => call.path), ["/api/chart"], "no extra engine call"); const moon = view.vedic.moon; assert.ok(moon); const natalMoonSign = golden.chart.planets.Moon.sign as string; const natalAsc = golden.chart.ascendant.sign as string; assert.equal(moon.moonSign, natalMoonSign); assert.equal(moon.chart.houses[0]!.sign, natalMoonSign, "house 1 is the Moon's sign"); assert.equal(moon.ascendantHouse, ((signIndex(natalAsc) - signIndex(natalMoonSign) + 12) % 12) + 1); for (const row of moon.planets) { const natal = view.vedic.planets.find((item) => item.name === row.name)!; assert.equal(row.sign, natal.sign, "signs are the D1's"); assert.equal(row.degreeInSign, natal.degreeInSign); assert.equal(row.house, ((signIndex(row.sign) - signIndex(natalMoonSign) + 12) % 12) + 1); } assert.equal(moon.planets.find((row) => row.name === "Moon")?.house, 1); const markup = renderToStaticMarkup(React.createElement(ChartVedicTab, { view, vargaId: "MOON", onSelectVarga: () => {} })); assert.match(markup, /月亮盘 · 以月亮所在星座([^)]+)为第 1 宫<\/caption>/); assert.deepEqual(ascendantMarkHouses(markup), [moon.ascendantHouse], "the lagna mark sits in the natal ascendant's house"); }); test("a chart counted from the ascendant still draws the lagna mark in house 1 only", async () => { const { view } = await assemble([]); const svg = renderToStaticMarkup(React.createElement(VedicChartSvg, { chart: view.vedic.vargas[0]!.chart, ariaLabel: "D1" })); assert.deepEqual(ascendantMarkHouses(svg), [1]); }); test("the transit layer posts today's noon with the natal ayanamsa and node mode, under its own cache scope", async () => { const { view, calls } = await assemble(["transit"]); const natal = calls.find((call) => call.path === "/api/chart" && !call.cacheScope)!; const transit = calls.find((call) => call.cacheScope?.startsWith("transit:"))!; assert.ok(transit, "transit call made"); assert.equal(transit.path, "/api/chart"); assert.equal(transit.cacheScope, "transit:2026-09-15"); assert.deepEqual([transit.body.year, transit.body.month, transit.body.day, transit.body.hour, transit.body.minute], [2026, 9, 15, 12, 0]); assert.equal(transit.body.ayanamsa, natal.body.ayanamsa); assert.equal(transit.body.node_mode, natal.body.node_mode); assert.equal(transit.body.tz, natal.body.tz); assert.equal(transit.body.skip_vedastro_main_entry_overview, true); assert.equal(view.transit.status, "ok"); if (view.transit.status !== "ok") throw new Error("expected transit ok"); assert.equal(view.transit.date, "2026-09-15"); assert.equal(view.transit.time, "12:00"); assert.equal(view.transit.timezoneLabel, "Asia/Shanghai"); const natalAsc = golden.chart.ascendant.sign as string; assert.equal(view.transit.rows.length, 9); for (const row of view.transit.rows) { const engine = transitPacket.planets[row.name]; assert.equal(row.sign, engine.sign); assert.equal(row.natalHouse, ((signIndex(row.sign) - signIndex(natalAsc) + 12) % 12) + 1); assert.equal(row.natalPlanetHouse, golden.chart.planets[row.name].house); } assert.equal(view.transit.chart.houses[0]!.sign, natalAsc, "house 1 is the natal ascendant"); const markup = renderToStaticMarkup(React.createElement(ChartVedicTab, { view, vargaId: "TRANSIT", onSelectVarga: () => {} })); assert.match(markup, /行运 · 2026-09-15 12:00(Asia\/Shanghai)<\/caption>/); for (const column of ["行星", "今天的位置", "速度", "落本命", "本命同星"]) assert.match(markup, new RegExp(`${column}`)); assert.ok(markup.includes(CHART_VIEW_COPY.transitNote)); }); test("the transit tab waits, then fails with the layer copy, and is never requested for D1", async () => { const { view, calls } = await assemble([]); assert.equal(calls.some((call) => call.cacheScope), false); assert.equal(view.transit.status, "unavailable"); const render = (props: Record) => renderToStaticMarkup(React.createElement(ChartVedicTab, { view, vargaId: "TRANSIT", onSelectVarga: () => {}, ...props })); assert.match(render({ pending: true }), /data-skeleton="true"/); assert.ok(render({ pending: true }).includes(CHART_VIEW_COPY.waitingChart)); const failed = render({ pending: false, failure: "engine_busy" }); assert.ok(failed.includes(CHART_VIEW_COPY.busy)); assert.doesNotMatch(failed, /data-skeleton/); }); test("a scoped engine call can never share the natal chart's cache entry", () => { const base = { userId: "u", subjectId: "s", date: "1990-06-15", time: "12:00", latitude: 1, longitude: 2, timezoneOffset: 8, ayanamsa: "raman", nodeMode: "mean" }; const natal = chartViewEngineCacheKey({ ...base, path: chartViewEngineCachePath("/api/chart") }); const today = chartViewEngineCacheKey({ ...base, path: chartViewEngineCachePath("/api/chart", "transit:2026-09-15") }); const tomorrow = chartViewEngineCacheKey({ ...base, path: chartViewEngineCachePath("/api/chart", "transit:2026-09-16") }); assert.notEqual(natal, today); assert.notEqual(today, tomorrow); assert.equal(chartViewEngineCachePath("/api/chart"), "/api/chart", "unscoped keys are unchanged"); assert.match(serviceSource, /path: chartViewEngineCachePath\(path, cacheScope\)/); });