Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
154 lines
8.2 KiB
TypeScript
154 lines
8.2 KiB
TypeScript
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<string, unknown>; 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("</g></g>")[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, /<caption>月亮盘 · 以月亮所在星座([^)]+)为第 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, /<caption>行运 · 2026-09-15 12:00(Asia\/Shanghai)<\/caption>/);
|
||
for (const column of ["行星", "今天的位置", "速度", "落本命", "本命同星"]) assert.match(markup, new RegExp(`<th scope="col">${column}</th>`));
|
||
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<string, unknown>) => 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\)/);
|
||
});
|