- Report p33 table: no Kranti column (no producer; materials.kranti stays blocked). - Report p53 table: the "SAV" column was the with-Lagna total (386). Split in the reader into SAV / Lagna BAV / SAV + Lagna with a totals row; ashtakavarga.py is untouched (frozen rectification identity, ERR-115). - /api/chart ascendant carries nakshatra / pada / lord (_nakshatra_fields shared with the nine bodies); chart cache schema 4 -> 5. - /api/varga_full bodies carry dignity_level from varga_dignity_level, the function the Vimsopaka path now shares (A/B identical); the chart page shows the D1 status wording and no longer derives a three-level dignity itself. - Report page draws each chart once: the trailing block skips charts the body already fenced (same rule as the export); the birth-time caveat moves under the body's chart heading. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
294 lines
15 KiB
TypeScript
294 lines
15 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, VargaTable } from "../src/components/chart-page/chart-vedic-tab.tsx";
|
||
import { assembleChartView } from "../src/lib/chart-view-load.ts";
|
||
import { chartViewOkSchema, chartViewVargaSchema, type ChartViewOk, type ChartViewVarga } from "../src/lib/chart-view-contract.ts";
|
||
import { buildChartView } from "../src/lib/chart-view-mapper.ts";
|
||
|
||
Object.assign(globalThis, { React });
|
||
|
||
// Real engine output (see the fixture's sourceCommand); nothing here is hand-shaped.
|
||
const golden = JSON.parse(
|
||
readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8"),
|
||
) as {
|
||
chart: Record<string, unknown>;
|
||
varga_full: { result: Record<string, { ascendant: Record<string, unknown>; planets: Record<string, Record<string, unknown>> }> };
|
||
chara: Record<string, unknown>;
|
||
};
|
||
|
||
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 vargaView(): Promise<ChartViewOk> {
|
||
const result = await assembleChartView({
|
||
userId: "user-1",
|
||
profile,
|
||
asOf: "2026-09-15",
|
||
layers: ["varga"],
|
||
postEngine: async (path) => {
|
||
if (path === "/api/chart") return { status: "ok", payload: golden.chart };
|
||
if (path === "/api/varga_full") return { status: "ok", payload: golden.varga_full as unknown as Record<string, unknown> };
|
||
return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 };
|
||
},
|
||
});
|
||
assert.equal(result.body.status, "ok");
|
||
return result.body as ChartViewOk;
|
||
}
|
||
|
||
function render(view: ChartViewOk, vargaId: string): string {
|
||
return renderToStaticMarkup(React.createElement(ChartVedicTab, { view, vargaId, onSelectVarga: () => {} }));
|
||
}
|
||
|
||
/** The table row whose header cell names this body. */
|
||
function row(markup: string, label: string): string {
|
||
const tbody = markup.match(/<tbody>([\s\S]*?)<\/tbody>/)?.[1] ?? "";
|
||
const hit = [...tbody.matchAll(/<tr>([\s\S]*?)<\/tr>/g)].map((match) => match[1]!).find((cells) => {
|
||
const header = cells.match(/<th scope="row">([\s\S]*?)<\/th>/)?.[1] ?? "";
|
||
return header.includes(label);
|
||
});
|
||
assert.ok(hit, `no row for ${label}`);
|
||
return hit.replace(/<!-- -->/g, "");
|
||
}
|
||
|
||
function degreeMinutes(degree: number): string {
|
||
const whole = Math.floor(degree);
|
||
const minutes = Math.floor((degree - whole) * 60);
|
||
return `${whole}°${String(minutes).padStart(2, "0")}′`;
|
||
}
|
||
|
||
// BUG-1076: the D9 caption changed but every row stayed D1.
|
||
test("a divisional chart's table carries that chart's signs, degrees and houses", async () => {
|
||
const view = await vargaView();
|
||
const d9 = golden.varga_full.result.D9_Navamsa!;
|
||
const d1Markup = render(view, "D1");
|
||
const d9Markup = render(view, "D9");
|
||
|
||
const sunD9 = row(d9Markup, "太阳");
|
||
const moonD9 = row(d9Markup, "月亮");
|
||
assert.match(sunD9, /天秤/, "D9 Sun is in Libra");
|
||
assert.ok(sunD9.includes(degreeMinutes(Number(d9.planets.Sun!.degree))), "D9 Sun degree is the in-varga degree");
|
||
assert.match(moonD9, /双鱼/, "D9 Moon is in Pisces");
|
||
assert.match(moonD9, />4宫</, "D9 Moon house counts from the D9 ascendant");
|
||
assert.notEqual(sunD9, row(d1Markup, "太阳"));
|
||
assert.notEqual(moonD9, row(d1Markup, "月亮"));
|
||
|
||
const ascD9 = row(d9Markup, "上升");
|
||
assert.match(ascD9, /射手/, "D9 ascendant is Sagittarius");
|
||
});
|
||
|
||
test("the D1 table is byte-for-byte the table the page drew before BUG-1076, ascendant nakshatra aside", async () => {
|
||
const view = await vargaView();
|
||
const markup = render(view, "D1");
|
||
const table = markup.match(/<div class="chart-page-planet-table-wrap">[\s\S]*?<\/table><\/div>/)?.[0];
|
||
// Rendered from the baseline commit 5dc977ef with the same golden. The only
|
||
// change since is BUG-1201: the engine now gives the ascendant its nakshatra
|
||
// (Leo 29°23′ = Uttara Phalguni pada 1, lord Sun), where the baseline had dashes.
|
||
const baseline = readFileSync(new URL("./fixtures/chart-d1-table-baseline.html", import.meta.url), "utf8");
|
||
const dashes = '<td>—</td><td class="chart-page-planet-mid">—</td><td class="chart-page-planet-mid chart-page-planet-num">—</td><td>—</td>';
|
||
const filled = '<td>Uttara Phalguni</td><td class="chart-page-planet-mid"><span class="chart-page-planet-glyph is-sun">☉</span></td>'
|
||
+ '<td class="chart-page-planet-mid chart-page-planet-num">1</td><td>—</td>';
|
||
assert.equal(baseline.split(dashes).length, 2, "the baseline's ascendant row is the only all-dash row");
|
||
assert.equal(table, baseline.replace(dashes, filled));
|
||
});
|
||
|
||
// BUG-1201: the ascendant row's nakshatra cells were always dashes.
|
||
test("the D1 ascendant row shows the engine's nakshatra, lord and pada", async () => {
|
||
const view = await vargaView();
|
||
assert.equal(view.vedic.ascendant?.nakshatra, "Uttara Phalguni");
|
||
assert.equal(view.vedic.ascendant?.pada, 1);
|
||
const ascendant = row(render(view, "D1"), "上升");
|
||
assert.match(ascendant, /<td>Uttara Phalguni<\/td>/);
|
||
assert.match(ascendant, /is-sun/, "Uttara Phalguni's lord is the Sun");
|
||
// An older cached chart without the fields still renders, with dashes.
|
||
const chart = structuredClone(golden.chart);
|
||
for (const key of ["nakshatra", "nakshatra_pada", "nakshatra_lord"]) delete (chart.ascendant as Record<string, unknown>)[key];
|
||
const old = buildChartView({ chart, varga: null, chara: null, western: null, profile, asOf: "2026-09-15" });
|
||
assert.equal(old.vedic.ascendant?.nakshatra, null);
|
||
});
|
||
|
||
test("divisional rows carry engine dignity and vargottama, and drop nakshatra columns", async () => {
|
||
const view = await vargaView();
|
||
const d9 = view.vedic.vargas.find((item) => item.id === "D9");
|
||
assert.ok(d9?.rows);
|
||
const saturn = d9.rows.find((item) => item.name === "Saturn");
|
||
// D1 Saturn is in Capricorn and D9 Saturn is in Capricorn: own sign, vargottama.
|
||
assert.equal(saturn?.sign, "Capricorn");
|
||
assert.equal(saturn?.dignity, "OWN_SIGN");
|
||
assert.equal(saturn?.vargottama, true);
|
||
const sun = d9.rows.find((item) => item.name === "Sun");
|
||
assert.equal(sun?.sign, "Libra");
|
||
assert.equal(sun?.dignity, "DEBILITATED");
|
||
assert.equal(sun?.vargottama, false);
|
||
const markup = render(view, "D9");
|
||
const header = markup.match(/<thead>([\s\S]*?)<\/thead>/)?.[1] ?? "";
|
||
for (const column of ["行星", "分盘星座", "宫位", "本命星座", "庙旺", "同位"]) assert.match(header, new RegExp(`>${column}<`));
|
||
assert.doesNotMatch(header, /星宿|宿主|pada/);
|
||
// Same wording as the D1 status column (BUG-1202), friendship levels included.
|
||
assert.match(row(markup, "土星"), />入庙\(Own Sign\)<[\s\S]*>同位</);
|
||
assert.match(row(markup, "太阳"), />落陷\(Debilitated\)</);
|
||
assert.match(row(markup, "月亮"), />入友\(Friendly Sign\)</);
|
||
assert.match(row(markup, "火星"), />中性\(Neutral\)</);
|
||
assert.match(row(markup, "水星"), />入敌\(Enemy Sign\)</);
|
||
assert.match(row(markup, "木星"), />极敌\(Great Enemy\)</);
|
||
assert.match(row(markup, "上升"), />不适用</, "the ascendant has no sign dignity");
|
||
// Every divisional chart the engine returned has its own rows, D1 has none.
|
||
for (const varga of view.vedic.vargas) {
|
||
if (varga.id === "D1") assert.equal(varga.rows, undefined);
|
||
else assert.equal(varga.rows?.length, 9, varga.id);
|
||
}
|
||
});
|
||
|
||
function tableMarkup(varga: ChartViewVarga): string {
|
||
return renderToStaticMarkup(React.createElement(VargaTable, { varga }));
|
||
}
|
||
|
||
function assertColumns(markup: string, dignity: boolean, vargottama: boolean) {
|
||
const header = markup.match(/<thead>([\s\S]*?)<\/thead>/)?.[1] ?? "";
|
||
assert.equal(header.includes(">庙旺<"), dignity);
|
||
assert.equal(header.includes(">同位<"), vargottama);
|
||
const count = 4 + Number(dignity) + Number(vargottama);
|
||
assert.equal((header.match(/<th /g) ?? []).length, count);
|
||
const body = markup.match(/<tbody>([\s\S]*?)<\/tbody>/)?.[1] ?? "";
|
||
const rows = [...body.matchAll(/<tr>([\s\S]*?)<\/tr>/g)];
|
||
assert.equal(rows.length, 10, "the ascendant and all nine planets remain");
|
||
for (const [, cells] of rows) assert.equal((cells!.match(/<(?:th|td)\b/g) ?? []).length, count);
|
||
}
|
||
|
||
test("server capabilities retain dignity for every Dn and vargottama only for D9", async () => {
|
||
const view = await vargaView();
|
||
for (const varga of view.vedic.vargas) {
|
||
if (varga.id === "D1") {
|
||
assert.equal(varga.supportsDignity, undefined);
|
||
assert.equal(varga.supportsVargottama, undefined);
|
||
continue;
|
||
}
|
||
assert.equal(varga.supportsDignity, true, varga.id);
|
||
assert.equal(varga.supportsVargottama, varga.id === "D9", varga.id);
|
||
assertColumns(tableMarkup(varga), true, varga.id === "D9");
|
||
}
|
||
});
|
||
|
||
test("capability columns are independent and absent declarations hide whole columns", async () => {
|
||
const view = await vargaView();
|
||
const source = view.vedic.vargas.find((varga) => varga.id === "D9")!;
|
||
for (const dignity of [undefined, false, true]) {
|
||
for (const vargottama of [undefined, false, true]) {
|
||
const copy = structuredClone(source);
|
||
delete copy.supportsDignity;
|
||
delete copy.supportsVargottama;
|
||
if (dignity !== undefined) copy.supportsDignity = dignity;
|
||
if (vargottama !== undefined) copy.supportsVargottama = vargottama;
|
||
assertColumns(tableMarkup(chartViewVargaSchema.parse(copy)), dignity === true, vargottama === true);
|
||
}
|
||
}
|
||
});
|
||
|
||
test("valid false and null results never turn supported columns off", async () => {
|
||
const view = await vargaView();
|
||
const copy = structuredClone(view.vedic.vargas.find((varga) => varga.id === "D9")!);
|
||
for (const item of copy.rows!) {
|
||
item.dignity = null;
|
||
item.vargottama = false;
|
||
}
|
||
copy.ascendant!.vargottama = false;
|
||
const markup = tableMarkup(chartViewVargaSchema.parse(copy));
|
||
assertColumns(markup, true, true);
|
||
assert.doesNotMatch(markup.match(/<tbody>([\s\S]*?)<\/tbody>/)?.[1] ?? "", />同位<|\((?:Exalted|Own Sign|Debilitated|Neutral)\)</);
|
||
assert.match(row(markup, "上升"), />不适用</);
|
||
});
|
||
|
||
test("capability schema rejects wrong types and direct cache rendering requires strict true", async () => {
|
||
const view = await vargaView();
|
||
const source = view.vedic.vargas.find((varga) => varga.id === "D9")!;
|
||
for (const field of ["supportsDignity", "supportsVargottama"] as const) {
|
||
for (const value of [null, "true", "false", 1, 0, [], {}]) {
|
||
const copy = structuredClone(source);
|
||
Object.assign(copy, { [field]: value });
|
||
const parsed = chartViewVargaSchema.safeParse(copy);
|
||
assert.equal(parsed.success, false, `${field} must not coerce ${JSON.stringify(value)}`);
|
||
if (!parsed.success) assert.ok(parsed.error.issues.some((issue) => issue.path[0] === field && issue.code === "invalid_type"));
|
||
assertColumns(tableMarkup(copy), field !== "supportsDignity", field !== "supportsVargottama");
|
||
}
|
||
}
|
||
});
|
||
|
||
// BUG-1202: the dignity column is the engine's; it is never recomputed here.
|
||
test("a divisional payload without engine dignity hides the column instead of deriving it", () => {
|
||
const varga = structuredClone(golden.varga_full);
|
||
for (const item of Object.values(varga.result)) {
|
||
for (const planet of Object.values(item.planets)) delete planet.dignity_level;
|
||
}
|
||
const view = buildChartView({ chart: structuredClone(golden.chart), varga: varga as unknown as Record<string, unknown>, chara: null, western: null, profile, asOf: "2026-09-15" });
|
||
const d9 = view.vedic.vargas.find((item) => item.id === "D9")!;
|
||
assert.equal(d9.supportsDignity, false);
|
||
assert.equal(d9.supportsVargottama, true);
|
||
assert.ok(d9.rows!.every((item) => item.dignity === null));
|
||
assertColumns(tableMarkup(d9), false, true);
|
||
const unknown = structuredClone(golden.varga_full);
|
||
unknown.result.D9_Navamsa!.planets.Sun!.dignity_level = "SOMETHING_NEW";
|
||
const mixed = buildChartView({ chart: structuredClone(golden.chart), varga: unknown as unknown as Record<string, unknown>, chara: null, western: null, profile, asOf: "2026-09-15" });
|
||
assert.equal(mixed.vedic.vargas.find((item) => item.id === "D9")!.supportsDignity, false);
|
||
});
|
||
|
||
test("missing or invalid source positions are not manufactured as negative same-sign results", () => {
|
||
const chart = structuredClone(golden.chart);
|
||
const varga = structuredClone(golden.varga_full);
|
||
const d9 = varga.result.D9_Navamsa!;
|
||
delete d9.planets.Sun!.sign;
|
||
d9.planets.Moon!.sign = "not-a-sign";
|
||
const view = buildChartView({ chart, varga: varga as unknown as Record<string, unknown>, chara: null, western: null, profile, asOf: "2026-09-15" });
|
||
const projected = view.vedic.vargas.find((item) => item.id === "D9")!;
|
||
assert.equal(projected.supportsDignity, true);
|
||
assert.equal(projected.supportsVargottama, true);
|
||
assert.equal(projected.rows!.some((item) => item.name === "Sun" || item.name === "Moon"), false);
|
||
// Destroy only the source positions on a golden copy, rather than making a
|
||
// response shape. With no valid result source no capability is advertised.
|
||
for (const planet of Object.values(d9.planets)) delete planet.sign;
|
||
delete (chart.ascendant as Record<string, unknown>).sign;
|
||
const empty = buildChartView({ chart, varga: varga as unknown as Record<string, unknown>, chara: null, western: null, profile, asOf: "2026-09-15" });
|
||
const emptyD9 = empty.vedic.vargas.find((item) => item.id === "D9")!;
|
||
assert.deepEqual(emptyD9.rows, []);
|
||
assert.equal(emptyD9.ascendant, undefined);
|
||
assert.equal(emptyD9.supportsDignity, false);
|
||
assert.equal(emptyD9.supportsVargottama, false);
|
||
});
|
||
|
||
test("view projection omits engine branding but old profile fields still parse and raw metadata stays intact", async () => {
|
||
const before = structuredClone(golden);
|
||
const view = await vargaView();
|
||
assert.equal("engineName" in view.profile, false);
|
||
const legacy = structuredClone(view);
|
||
Object.assign(legacy.profile, { engineName: "Swiss Ephemeris(本站)" });
|
||
assert.equal("engineName" in chartViewOkSchema.parse(legacy).profile, false);
|
||
assert.deepEqual(golden, before, "the mapper never strips or changes the raw engine packet");
|
||
});
|
||
|
||
test("divisional house cells do not override their header's left alignment", async () => {
|
||
const view = await vargaView();
|
||
for (const varga of view.vedic.vargas.filter((item) => item.id !== "D1")) {
|
||
const markup = tableMarkup(varga);
|
||
const rows = [...markup.matchAll(/<tr>([\s\S]*?)<\/tr>/g)].slice(1);
|
||
assert.equal(rows.length, 10);
|
||
for (const [, cells] of rows) {
|
||
const tds = [...cells!.matchAll(/<td([^>]*)>([\s\S]*?)<\/td>/g)];
|
||
assert.equal(tds[1]![1], "", "house cells inherit the same alignment and padding as the header");
|
||
assert.match(tds[1]![2]!, /^\d+宫$/);
|
||
assert.match(tds[3]![1]!, /chart-page-planet-mid/, "the dignity column's deliberate centering remains");
|
||
}
|
||
}
|
||
});
|