Files
Jyotisha/frontend/tests/chart-moon-transit.test.tsx
T
jesse-uxandClaude Code 64aa0ec20c
Independent Staging Quality Gate / publish (push) Canceled after 0s
Independent Staging Quality Gate / validate (push) Canceled after 1m27s
feat(chart): polish chart presentation and symbol explanations
Hide engine branding, gate supported divisional columns, align house cells, and add accessible symbol help. Reduce desktop chart width and stabilize the waiting slot.

Validated with 78 browser checks and 87 focused frontend tests. Full suite retains 29 identical baseline loader failures; no new failures.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 19:03:28 +08:00

169 lines
8.9 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");
});
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));
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\)/);
});