Files
Jyotisha/frontend/tests/chart-moon-transit.test.tsx
T
jesse-uxGrok (agent loop)Cursor
d2edf8b7a8 fix(chart): 上升的状态格改成「/」
上升不是行星,本命、月亮盘和分盘的状态格不再写「不适用」。悬停或点一下说明原因。报告同一格写「/」,并在表下加中英文说明。

Co-Authored-By: Grok (agent loop)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 00:03:19 +08:00

180 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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");
const table = markup.slice(markup.indexOf("月亮盘"));
const ascendantRow = table.match(/<th scope="row">[\s\S]*?上升[\s\S]*?<\/tr>/)?.[0] ?? "";
assert.match(ascendantRow, /<td>Uttara Phalguni<\/td>/);
assert.match(ascendantRow, /is-sun/);
assert.match(ascendantRow, />1</);
// 原值:/>不适用</
// 新值:chart-page-asc-status 与说明句
// 原因:issue #87。月亮盘与本命共用上升行,状态格改「/」。
assert.match(ascendantRow, /chart-page-asc-status/);
assert.match(ascendantRow, /data-chart-help="上升点是命盘的起点,不是行星,不判断入旺或落陷"/);
});
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.match(markup, /<th scope="col" class="chart-page-planet-mid">速度<\/th>/);
assert.ok(markup.includes(CHART_VIEW_COPY.transitNote));
assert.match(markup, /<dl class="chart-page-params-table">/);
assert.match(markup, /<dt>岁差<\/dt>/);
assert.match(markup, /<dt>交点<\/dt>/);
assert.doesNotMatch(markup, /Swiss Ephemeris|>引擎</);
const body = markup.match(/<tbody>([\s\S]*?)<\/tbody>/)![1]!;
const rows = [...body.matchAll(/<tr>([\s\S]*?)<\/tr>/g)];
assert.equal(rows.length, 9);
for (const [, cells] of rows) {
const tds = [...cells!.matchAll(/<td([^>]*)>([\s\S]*?)<\/td>/g)];
for (const index of [2, 3]) {
assert.equal(tds[index]![1], "", "both house columns inherit their header's left alignment");
assert.match(tds[index]![2]!, /^\d+宫$/);
}
assert.match(tds[1]![1]!, /chart-page-planet-mid/, "direction remains centered");
}
});
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\)/);
});