fix(chart): Sripati Bhava from the real sidereal MC; Bhava chart and tables (T5, BUG-1077)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
Jesse_Chen
2026-09-28 15:38:24 +08:00
co-authored by Claude Opus 5.5
parent 3f6bcb081a
commit c35e0195e1
16 changed files with 743 additions and 9 deletions
+115
View File
@@ -0,0 +1,115 @@
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 { assembleChartView } from "../src/lib/chart-view-load.ts";
import { chartViewOkSchema, type ChartViewOk } from "../src/lib/chart-view-contract.ts";
import { CHART_VIEW_COPY } from "../src/lib/chart-view-labels.ts";
Object.assign(globalThis, { React });
const golden = JSON.parse(readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8"));
// Real `/api/bhava_chalit` response to the body chart-view-load sends (see its sourceCommand).
const bhavaGolden = JSON.parse(readFileSync(new URL("./fixtures/chart-view-bhava-golden.json", 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",
};
async function assemble(bhavaPayload: Record<string, unknown> = bhavaGolden.bhava_chalit) {
const calls: Array<{ path: string; body: Record<string, unknown> }> = [];
const result = await assembleChartView({
userId: "user-1",
profile,
asOf: "2026-09-15",
layers: ["bhava"],
postEngine: async (path, body) => {
calls.push({ path, body });
if (path === "/api/chart") return { status: "ok", payload: golden.chart };
if (path === "/api/bhava_chalit") return { status: "ok", payload: bhavaPayload };
return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 };
},
});
assert.equal(result.body.status, "ok");
return { view: chartViewOkSchema.parse(result.body), calls };
}
function render(view: ChartViewOk, props: Record<string, unknown> = {}) {
return renderToStaticMarkup(React.createElement(ChartVedicTab, { view, vargaId: "BHAVA", onSelectVarga: () => {}, ...props }));
}
test("the bhava layer asks for Sripati with the birth moment the MC needs (BUG-1077)", async () => {
const { calls } = await assemble();
const call = calls.find((item) => item.path === "/api/bhava_chalit");
assert.ok(call);
assert.equal(call.body.house_system, "sripati", "the western wheel's house_system P must not leak in");
assert.equal(call.body.mode, "compare");
for (const key of ["year", "month", "day", "hour", "minute", "lat", "lon", "tz", "ayanamsa"]) assert.ok(call.body[key] !== undefined, key);
assert.equal(call.body.mc_lon, undefined, "the page never sends a guessed MC");
assert.ok(call.body.planets && call.body.ascendant);
});
test("Sripati rows, cusps and the drawn houses follow the engine's Bhava houses", async () => {
const { view } = await assemble();
assert.equal(view.bhava.status, "ok");
if (view.bhava.status !== "ok") throw new Error("expected bhava ok");
const engine = bhavaGolden.bhava_chalit.result;
assert.equal(view.bhava.rows.length, 9);
for (const row of view.bhava.rows) {
assert.equal(row.bhavaHouse, engine.bhava_chart[row.name]);
assert.equal(row.rashiHouse, engine.rashi_chart[row.name].house);
}
const shifted = view.bhava.rows.filter((row) => row.shifted).map((row) => row.name).sort();
assert.deepEqual(shifted, engine.shifts.map((item: { planet: string }) => item.planet).sort());
assert.ok(shifted.length > 0, "this chart has at least one body that changes house");
assert.equal(view.bhava.cusps.length, 12);
// Not the old equal-house chart: the boundary spans differ.
const starts = view.bhava.cusps.map((cusp) => cusp.sandhiDegreeInSign);
assert.ok(new Set(starts.map((value) => value.toFixed(2))).size > 1);
for (const row of view.bhava.rows) {
const house = view.bhava.chart.houses[row.bhavaHouse - 1]!;
assert.ok(house.occupantGlyphs?.some((glyph) => glyph.key === row.name.toLowerCase()), `${row.name} drawn in Bhava house ${row.bhavaHouse}`);
}
const markup = render(view);
assert.ok(markup.includes(CHART_VIEW_COPY.bhavaNote));
assert.match(markup, /<caption>Bhava · Sripati<\/caption>/);
assert.match(markup, /<caption>宫头与宫界<\/caption>/);
assert.equal((markup.match(/class="is-shifted"/g) ?? []).length, shifted.length);
assert.equal((markup.match(/<td class="chart-page-planet-mid">是<\/td>/g) ?? []).length, shifted.length);
});
test("an equal-house placeholder MC or a house-system fallback is never drawn", async () => {
const response = bhavaGolden.bhava_chalit;
const asc = response.result.ascendant_lon as number;
const placeholder = structuredClone(response);
placeholder.result.mc_lon = (asc + 270) % 360;
const fallback = structuredClone(response);
fallback.selected_house_system = "sripati";
fallback.fallback_reason = "placidus requires swisseph plus valid birth date";
for (const payload of [placeholder, fallback]) {
const { view } = await assemble(payload);
assert.equal(view.bhava.status, "unavailable");
const markup = render(view, { failure: "unavailable" });
assert.doesNotMatch(markup, /宫头与宫界|data-skeleton/);
}
});
test("the bhava tab waits with the chart skeleton and shows the layer failure copy", async () => {
const { view } = await assemble();
const bare = { ...view, bhava: { status: "unavailable" as const, reason: "not_requested", copy: CHART_VIEW_COPY.unavailable } };
assert.match(render(bare, { pending: true }), /data-skeleton="true"/);
assert.ok(render(bare, { pending: false, failure: "engine_busy" }).includes(CHART_VIEW_COPY.busy));
});
+5 -4
View File
@@ -12,6 +12,7 @@ import { peekChartPage, pinChartSnapshotIdentity, prefetchSecondaryPage, resetSe
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
const golden = JSON.parse(readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8"));
const bhavaGolden = JSON.parse(readFileSync(new URL("./fixtures/chart-view-bhava-golden.json", import.meta.url), "utf8"));
const transitGolden = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-chart-transit-2026-09-15.golden.json", import.meta.url), "utf8"));
const profile = { name: "Synthetic", date: "1990-06-15", time: "12:00", placeLabel: "Synthetic", latitude: 39.9042, longitude: 116.4074, timezoneOffset: 8, timezoneId: "Asia/Shanghai", ayanamsa: "raman", birthTimeStatus: "confirmed" };
async function packet(layer?: ChartViewLayer, failure?: EngineCallResult) {
@@ -20,7 +21,7 @@ async function packet(layer?: ChartViewLayer, failure?: EngineCallResult) {
return assembleChartView({ userId: "synthetic", accountId: "synthetic", profileFingerprint: "fixture-v1", profile, asOf: "2026-09-15", layers: layer ? [layer] : [], postEngine: async (path, _body, cacheScope) => {
if (path === "/api/chart" && !cacheScope) return { status: "ok", payload: golden.chart };
if (failure) return failure;
const payload = path === "/api/varga_full" ? golden.varga_full : path === "/api/dasha/chara" ? golden.chara : path === "/api/chart" ? transitGolden : golden.western;
const payload = path === "/api/varga_full" ? golden.varga_full : path === "/api/dasha/chara" ? golden.chara : path === "/api/chart" ? transitGolden : path === "/api/bhava_chalit" ? bhavaGolden.bhava_chalit : golden.western;
return { status: "ok", payload };
} });
}
@@ -84,9 +85,9 @@ for (const cached of [false, true]) test(`chart 401 ${cached ? "cache" : "respon
assert.equal(state().view?.status, "unauthenticated"); assert.deepEqual(assigned, ["/login"]);
}));
// 原值: ["varga", "chara", "western", "qizheng"];新值: 去掉 qizheng、加 transit
// 原因: 七政懒加载层已删(TASK-chart-types D1),行运层新增(T4)
for (const layer of ["varga", "chara", "western", "transit"] as const) test(`real assemble to fetch to hook preserves ${layer} engine 429`, async () => harness(async (h, state) => {
// 原值: ["varga", "chara", "western", "qizheng"];新值: 去掉 qizheng、加 bhava 与 transit
// 原因: 七政懒加载层已删(TASK-chart-types D1),行运层(T4)与 Bhava 层(T5)新增
for (const layer of ["varga", "chara", "western", "bhava", "transit"] as const) test(`real assemble to fetch to hook preserves ${layer} engine 429`, async () => harness(async (h, state) => {
const initial = await packet();
const failed = await packet(layer, { status: "busy", path: "/api/synthetic", elapsedMs: 1, httpStatus: 429 });
globalThis.fetch = async (url) => response(String(url).includes("layers=") ? failed : initial);
+294
View File
@@ -0,0 +1,294 @@
{
"sourceCommand": "python3: jyotish_api_server.JyotishAPIHandler._compute_bhava_chalit with the chart-view-load body (birth payload + chart-view-golden chart.planets/ascendant/houses, house_system=sripati, mode=compare) after BUG-1077 (MC from bhava_chalit_mc.sidereal_mc_from_birth). Fictional 1990-06-15 12:00 Beijing, Raman.",
"bhava_chalit": {
"success": true,
"endpoint": "bhava_chalit",
"mode": "compare",
"requested_house_system": "sripati",
"selected_house_system": "sripati",
"available_house_systems": [
"equal",
"koch",
"placidus",
"porphyry",
"sripati",
"whole_sign"
],
"fallback_reason": "",
"calculation_note": "Bhava Chalit uses Sripati (Vedic standard, Porphyry variant).",
"result": {
"house_system": "sripati",
"house_system_desc": "Sripati (Vedic standard, Porphyry variant)",
"ascendant_lon": 149.3994,
"mc_lon": 58.152,
"rashi_chart": {
"Sun": {
"sign": "Gemini",
"house": 11,
"degree_in_sign": 1.5338
},
"Moon": {
"sign": "Aquarius",
"house": 7,
"degree_in_sign": 18.6462
},
"Mars": {
"sign": "Pisces",
"house": 8,
"degree_in_sign": 18.5242
},
"Mercury": {
"sign": "Taurus",
"house": 10,
"degree_in_sign": 12.8439
},
"Jupiter": {
"sign": "Gemini",
"house": 11,
"degree_in_sign": 23.5398
},
"Venus": {
"sign": "Aries",
"house": 9,
"degree_in_sign": 26.1082
},
"Saturn": {
"sign": "Capricorn",
"house": 6,
"degree_in_sign": 1.774
},
"Rahu": {
"sign": "Capricorn",
"house": 6,
"degree_in_sign": 17.4381
},
"Ketu": {
"sign": "Cancer",
"house": 12,
"degree_in_sign": 17.4381
}
},
"bhava_chart": {
"Sun": 10,
"Moon": 7,
"Mars": 8,
"Mercury": 9,
"Jupiter": 11,
"Venus": 9,
"Saturn": 5,
"Rahu": 6,
"Ketu": 12
},
"shifts": [
{
"planet": "Sun",
"sign": "Gemini",
"degree_in_sign": 1.5338,
"rashi_house": 11,
"bhava_house": 10,
"shift_direction": "backward",
"shift_magnitude": 1,
"note": "Sun 从第11宫偏移到第10宫 (backward)"
},
{
"planet": "Mercury",
"sign": "Taurus",
"degree_in_sign": 12.8439,
"rashi_house": 10,
"bhava_house": 9,
"shift_direction": "backward",
"shift_magnitude": 1,
"note": "Mercury 从第10宫偏移到第9宫 (backward)"
},
{
"planet": "Saturn",
"sign": "Capricorn",
"degree_in_sign": 1.774,
"rashi_house": 6,
"bhava_house": 5,
"shift_direction": "backward",
"shift_magnitude": 1,
"note": "Saturn 从第6宫偏移到第5宫 (backward)"
}
],
"shifted_count": 3,
"boundaries": {
"house_system": "sripati",
"house_system_desc": "Sripati (Vedic standard, Porphyry variant)",
"ascendant_lon": 149.3994,
"mc_lon": 58.152,
"cusps": [
149.3994,
178.9836,
208.5678,
238.152,
268.5678,
298.9836,
329.3994,
358.9836,
28.5678,
58.152,
88.5678,
118.9836
],
"sandhis": [
134.1915,
164.1915,
193.7757,
223.3599,
253.3599,
283.7757,
314.1915,
344.1915,
13.7757,
43.3599,
73.3599,
103.7757
],
"houses": [
{
"house": 1,
"cusp_lon": 149.3994,
"cusp_sign": "Leo",
"cusp_sign_cn": "狮子座",
"cusp_degree_in_sign": 29.3994,
"sandhi_start_lon": 134.1915,
"sandhi_end_lon": 164.1915,
"span_degrees": 30.0
},
{
"house": 2,
"cusp_lon": 178.9836,
"cusp_sign": "Virgo",
"cusp_sign_cn": "处女座",
"cusp_degree_in_sign": 28.9836,
"sandhi_start_lon": 164.1915,
"sandhi_end_lon": 193.7757,
"span_degrees": 29.5842
},
{
"house": 3,
"cusp_lon": 208.5678,
"cusp_sign": "Libra",
"cusp_sign_cn": "天秤座",
"cusp_degree_in_sign": 28.5678,
"sandhi_start_lon": 193.7757,
"sandhi_end_lon": 223.3599,
"span_degrees": 29.5842
},
{
"house": 4,
"cusp_lon": 238.152,
"cusp_sign": "Scorpio",
"cusp_sign_cn": "天蝎座",
"cusp_degree_in_sign": 28.152,
"sandhi_start_lon": 223.3599,
"sandhi_end_lon": 253.3599,
"span_degrees": 30.0
},
{
"house": 5,
"cusp_lon": 268.5678,
"cusp_sign": "Sagittarius",
"cusp_sign_cn": "射手座",
"cusp_degree_in_sign": 28.5678,
"sandhi_start_lon": 253.3599,
"sandhi_end_lon": 283.7757,
"span_degrees": 30.4158
},
{
"house": 6,
"cusp_lon": 298.9836,
"cusp_sign": "Capricorn",
"cusp_sign_cn": "摩羯座",
"cusp_degree_in_sign": 28.9836,
"sandhi_start_lon": 283.7757,
"sandhi_end_lon": 314.1915,
"span_degrees": 30.4158
},
{
"house": 7,
"cusp_lon": 329.3994,
"cusp_sign": "Aquarius",
"cusp_sign_cn": "水瓶座",
"cusp_degree_in_sign": 29.3994,
"sandhi_start_lon": 314.1915,
"sandhi_end_lon": 344.1915,
"span_degrees": 30.0
},
{
"house": 8,
"cusp_lon": 358.9836,
"cusp_sign": "Pisces",
"cusp_sign_cn": "双鱼座",
"cusp_degree_in_sign": 28.9836,
"sandhi_start_lon": 344.1915,
"sandhi_end_lon": 13.7757,
"span_degrees": 29.5842
},
{
"house": 9,
"cusp_lon": 28.5678,
"cusp_sign": "Aries",
"cusp_sign_cn": "白羊座",
"cusp_degree_in_sign": 28.5678,
"sandhi_start_lon": 13.7757,
"sandhi_end_lon": 43.3599,
"span_degrees": 29.5842
},
{
"house": 10,
"cusp_lon": 58.152,
"cusp_sign": "Taurus",
"cusp_sign_cn": "金牛座",
"cusp_degree_in_sign": 28.152,
"sandhi_start_lon": 43.3599,
"sandhi_end_lon": 73.3599,
"span_degrees": 30.0
},
{
"house": 11,
"cusp_lon": 88.5678,
"cusp_sign": "Gemini",
"cusp_sign_cn": "双子座",
"cusp_degree_in_sign": 28.5678,
"sandhi_start_lon": 73.3599,
"sandhi_end_lon": 103.7757,
"span_degrees": 30.4158
},
{
"house": 12,
"cusp_lon": 118.9836,
"cusp_sign": "Cancer",
"cusp_sign_cn": "巨蟹座",
"cusp_degree_in_sign": 28.9836,
"sandhi_start_lon": 103.7757,
"sandhi_end_lon": 134.1915,
"span_degrees": 30.4158
}
]
},
"summary": {
"total_planets": 9,
"shifted_count": 3,
"shifted_names": [
"Sun",
"Mercury",
"Saturn"
]
},
"requested_house_system": "sripati",
"selected_house_system": "sripati",
"available_house_systems": [
"equal",
"koch",
"placidus",
"porphyry",
"sripati",
"whole_sign"
],
"fallback_reason": "",
"calculation_note": "Bhava Chalit uses Sripati (Vedic standard, Porphyry variant)."
}
}
}