Files
Jyotisha/frontend/tests/chart-board-20261027.test.tsx
T
Jesse_ChenandClaude Opus 5.5 36fd4b2c83
Independent Staging Quality Gate / validate (push) Successful in 23m11s
Independent Staging Quality Gate / publish (push) Successful in 5m14s
Chart page back to the signed-off prototype (issue #114, round 5)
Round 3 followed the competitor screenshots instead of prototype v2.
Restored from the prototype source: page head (name, system switch,
report and chat buttons), layer row left / style right, the caption
strip with dasha chips, north board drawn from house centroids with sign
glyph + name + house number, dignity colours and " R", twelve house
theme chips with dots, wrapped antardasha chips, card hints, inspector
empty state, deep-reading hints, chevron date steps and 此刻, year axis
under the mahadasha scroll, three-column birth data. Point layers stay
in the layer group; side triangles stack bodies so nothing leaves the
frame (BUG-1465 test kept).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Ad9rqAB8pXXvyzh7f4X8v
2026-10-27 17:28:14 +08:00

370 lines
20 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 { ChartBoardSvg } from "../src/components/chart-board/chart-board-svg.tsx";
import { ChartInspector, inspectorQuestion } from "../src/components/chart-board/chart-inspector.tsx";
import { ChartGrahaCard } from "../src/components/chart-board/chart-side-cards.tsx";
import {
aspectedHouses,
boardRelations,
bodyHouse,
dignityStep,
houseLord,
lordedHouses,
stepSelection,
} from "../src/lib/chart-board-model.ts";
import { boardSource, glanceOf, natalBoard, savBand, transitBySign } from "../src/lib/chart-board-view.ts";
import { readStoredChartBoardStyle, writeStoredChartBoardStyle } from "../src/lib/chart-board-style.ts";
import { assembleChartView } from "../src/lib/chart-view-load.ts";
import type { ChartViewOk } from "../src/lib/chart-view-contract.ts";
import { CHART_VIEW_LAYERS, parseTransitDate } from "../src/lib/chart-view-engine.ts";
import { mapAshtakavarga, mapBirthCalendar, mapPoints, mapShadbala, mapYogas, shadbalaLevel } from "../src/lib/chart-view-insight.ts";
import { pointSelection, pointsOf, stepPoint } from "../src/lib/chart-board-view.ts";
import { vimshottariSplit } from "../src/lib/dasha-span.ts";
import { HOUSE_POLYGONS, pointInPolygon } from "../src/lib/north-indian-chart-geometry.ts";
import { SIGN_ORDER, signZh } 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")) as {
chart: Record<string, unknown>;
varga_full: Record<string, unknown>;
};
/** Real engine output (see `sourceCommand`), trimmed to the fields the page reads. */
const insight = JSON.parse(readFileSync(new URL("./fixtures/chart-insight-golden.json", import.meta.url), "utf8")) as {
chart: Record<string, unknown>;
chart_modules_shadbala: Record<string, unknown>;
ashtakavarga: Record<string, unknown>;
bhava_bala: Record<string, unknown>;
chart_points: Record<string, unknown>;
second_case: {
yogas: unknown[];
vimshottari_current: { antardasha: { lord: string; start: string; end: string } };
five_levels: { pratyantar: { lord: string; total_years: number } };
};
};
const insightChart = { ...insight.chart, modules: { shadbala: insight.chart_modules_shadbala } };
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 okView(): Promise<ChartViewOk> {
const result = await assembleChartView({
userId: "user-1",
profile,
asOf: "2026-10-27",
layers: CHART_VIEW_LAYERS,
postEngine: async (path) => {
if (path === "/api/chart") return { status: "ok", payload: { ...golden.chart, ...insightChart } };
if (path === "/api/varga_full") return { status: "ok", payload: golden.varga_full };
if (path === "/api/ashtakavarga") return { status: "ok", payload: insight.ashtakavarga };
if (path === "/api/bhava_bala") return { status: "ok", payload: insight.bhava_bala };
if (path === "/api/chart_points") return { status: "ok", payload: insight.chart_points };
return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 };
},
});
assert.equal(result.body.status, "ok");
return result.body as ChartViewOk;
}
test("shadbala rows keep the engine totals and minimum, and print a strong/medium/weak band", () => {
const rows = mapShadbala(insightChart);
assert.equal(rows.length, 7);
const sun = rows.find((row) => row.name === "Sun")!;
assert.equal(sun.required, 6.5);
assert.equal(sun.percent, Math.round((sun.rupas / 6.5) * 1000) / 10);
assert.ok(sun.parts, "the six components come from modules.shadbala");
const mercury = rows.find((row) => row.name === "Mercury")!;
assert.equal(mercury.level, "medium", "6.94 of 7.0 rupas is 99 %");
assert.equal(shadbalaLevel(120), "strong");
assert.equal(shadbalaLevel(119.9), "medium");
assert.equal(shadbalaLevel(89.9), "weak");
assert.deepEqual(mapShadbala({}), [], "no packet, no rows; nothing is estimated");
});
test("yogas come from the consultation's closed list; reductions mark a partial yoga", () => {
const none = mapYogas(insightChart);
assert.deepEqual(none.formed, [], "Amala Yoga is outside the closed list");
assert.equal(none.notFormed.length, 8);
const second = mapYogas({ yogas: insight.second_case.yogas });
assert.deepEqual(second.formed.map((yoga) => yoga.id), ["ruchaka", "sasa"]);
assert.ok(second.formed.every((yoga) => yoga.reductions.length > 0));
assert.deepEqual(second.formed[0]!.planets, ["Mars"]);
assert.equal(second.notFormed.length, 6);
});
test("the ashtakavarga layer keeps SAV by sign, three BAV readings and house strength", () => {
const layer = mapAshtakavarga(insight.ashtakavarga, insight.bhava_bala);
assert.equal(layer.status, "ok");
if (layer.status !== "ok") return;
assert.equal(layer.sav.reduce((sum, value) => sum + value, 0), 337);
assert.deepEqual(layer.bav.map((row) => row.raw.reduce((a, b) => a + b, 0)), [48, 49, 39, 54, 56, 52, 39]);
for (const row of layer.bav) {
row.sodhita.forEach((value, index) => assert.ok(value <= row.trikona[index]! && row.trikona[index]! <= row.raw[index]!));
}
assert.equal(layer.bhavaBala?.length, 12);
assert.equal(mapAshtakavarga(null, null).status, "unavailable");
const withoutHouses = mapAshtakavarga(insight.ashtakavarga, null);
assert.equal(withoutHouses.status === "ok" && withoutHouses.bhavaBala, null, "house strength is optional");
});
test("the birth calendar prints the tithi in Chinese and the civil weekday", () => {
assert.deepEqual(mapBirthCalendar(insightChart, "1990-06-15"), { tithi: "黑分初七", weekday: "星期五" });
assert.deepEqual(mapBirthCalendar({}, "1990-06-15"), { tithi: null, weekday: "星期五" });
});
test("transitDate accepts any real calendar day in 1000–2999 and nothing else", () => {
assert.equal(parseTransitDate("2031-02-28"), "2031-02-28");
assert.equal(parseTransitDate("1850-07-01"), "1850-07-01");
assert.equal(parseTransitDate("2031-02-30"), undefined);
assert.equal(parseTransitDate("0999-01-01"), undefined);
assert.equal(parseTransitDate("2031-2-3"), undefined);
assert.equal(parseTransitDate(null), undefined);
});
test("a transit for another day posts that day under its own cache scope", async () => {
const calls: Array<{ path: string; body: Record<string, unknown>; scope?: string }> = [];
const result = await assembleChartView({
userId: "user-1",
profile,
asOf: "2026-10-27",
transitDate: "2031-02-28",
layers: ["transit"],
postEngine: async (path, body, scope) => {
calls.push({ path, body, scope });
return { status: "ok", payload: golden.chart };
},
});
const transitCall = calls.find((call) => call.scope?.startsWith("transit:"));
assert.equal(transitCall?.scope, "transit:2031-02-28");
assert.deepEqual([transitCall?.body.year, transitCall?.body.month, transitCall?.body.day], [2031, 2, 28]);
assert.equal(result.body.status === "ok" && result.body.transit.status === "ok" ? result.body.transit.date : null, "2031-02-28");
});
test("the third dasha level uses the same split as the engine's current pratyantar", () => {
const { antardasha } = insight.second_case.vimshottari_current;
const spans = vimshottariSplit(antardasha.lord, antardasha.start, antardasha.end);
assert.equal(spans.length, 9);
assert.equal(spans[0]!.lord, antardasha.lord);
assert.equal(spans[0]!.start, antardasha.start);
assert.equal(spans.at(-1)!.end, antardasha.end);
spans.slice(1).forEach((span, index) => assert.equal(span.start, spans[index]!.end));
const engine = insight.second_case.five_levels.pratyantar;
const now = spans.find((span) => span.start <= "2026-10-27" && "2026-10-27" < span.end)!;
assert.equal(now.lord, engine.lord, "the engine computed this run on 2026-10-27");
const days = (Date.parse(now.end) - Date.parse(now.start)) / 86_400_000;
assert.ok(Math.abs(days - engine.total_years * 365.25) <= 1.5);
});
test("rulership, graha drishti and relations follow the engine's rules", async () => {
const view = await okView();
const model = natalBoard(view);
assert.equal(model.houses[0]!.sign, 4, "Leo rising");
assert.equal(houseLord(model, 1), "sun");
assert.deepEqual(lordedHouses(model, "saturn"), [6, 7]);
assert.deepEqual(lordedHouses(model, "rahu"), []);
const mars = bodyHouse(model, "mars")!;
assert.deepEqual(aspectedHouses(model, "mars"), [4, 7, 8].map((n) => ((mars + n - 2) % 12) + 1));
const relations = boardRelations(model, { kind: "body", key: "jupiter" });
assert.equal(relations.focus, bodyHouse(model, "jupiter"));
assert.ok(relations.lit.has(relations.focus!));
relations.aspectLinks.forEach((house) => assert.ok(aspectedHouses(model, "jupiter").includes(house)));
const house = boardRelations(model, { kind: "house", house: 1 });
assert.deepEqual(house.lordLinks, bodyHouse(model, "sun") === 1 ? [] : [bodyHouse(model, "sun")]);
assert.deepEqual(stepSelection(model, { kind: "house", house: 12 }, 1), { kind: "house", house: 1 });
assert.deepEqual(stepSelection(model, { kind: "body", key: "sun" }, -1), { kind: "body", key: "ketu" });
});
test("dignity reads the engine's status label on a nine-step scale", () => {
assert.deepEqual(dignityStep("入旺(Exalted)"), { step: 8, label: "入旺" });
assert.deepEqual(dignityStep("落陷(Debilitated)"), { step: 0, label: "落陷" });
assert.deepEqual(dignityStep("落陷取消(Neecha Bhanga)"), { step: 1, label: "落陷取消" });
assert.deepEqual(dignityStep("中性(Neutral) · 逆行"), { step: 3, label: "中性" });
assert.equal(dignityStep("—"), null);
});
test("SAV bands use the traditional 28 line", () => {
assert.equal(savBand(28), "strong");
assert.equal(savBand(25), "medium");
assert.equal(savBand(24), "weak");
});
test("north and south boards draw twelve houses, every body, and lines for a selection", async () => {
const view = await okView();
const model = natalBoard(view);
for (const style of ["north", "south"] as const) {
const plain = renderToStaticMarkup(React.createElement(ChartBoardSvg, { model, style, selection: null, onSelect: () => {}, ariaLabel: "本命盘" }));
assert.equal((plain.match(/class="chart-board-cell/g) ?? []).length, 12);
// 原值:/class="chart-board-body /(类名后总跟一个星体颜色类)。新值:/class="chart-board-body[ "]/。
// 原因:按原型改为按庙旺/落陷着色,没有尊贵变化的星类名后面直接是引号;仍数九颗星。
assert.equal((plain.match(/class="chart-board-body[ "]/g) ?? []).length, 9);
assert.doesNotMatch(plain, /chart-board-link/);
const picked = renderToStaticMarkup(React.createElement(ChartBoardSvg, { model, style, selection: { kind: "body", key: "saturn" }, onSelect: () => {}, ariaLabel: "本命盘" }));
const relations = boardRelations(model, { kind: "body", key: "saturn" });
assert.equal((picked.match(/class="chart-board-link"/g) ?? []).length, relations.lordLinks.length);
assert.equal((picked.match(/class="chart-board-link is-aspect"/g) ?? []).length, relations.aspectLinks.length);
assert.match(picked, /chart-board-cell is-selected/);
}
});
// Issue #114 round 3: the North board wrote every sign name at its house's inner corner, so the
// four kendra names piled up in the centre and the triangle names met at the diagonals; bodies in
// the right-hand triangles were pushed past the frame. Each name and body must sit in its own house.
test("north board: every sign name and body sits inside its own house, and no two names meet", async () => {
const view = await okView();
const model = natalBoard(view);
const markup = renderToStaticMarkup(React.createElement(ChartBoardSvg, { model, style: "north", selection: null, onSelect: () => {}, ariaLabel: "本命盘" }));
const labels = [...markup.matchAll(/<text class="chart-board-sign[^"]*" x="([\d.]+)" y="([\d.]+)" text-anchor="(\w+)">\S+ ([^<]+)/g)]
.map((match) => ({ x: Number(match[1]), y: Number(match[2]), anchor: match[3]!, text: match[4]! }));
assert.equal(labels.length, 12);
for (const label of labels) {
const house = model.houses.find((item) => signZh(SIGN_ORDER[item.sign] ?? "") === label.text)!;
// Glyph, space and name at the 10-unit label font.
const width = 10 * (label.text.length + 2);
const left = label.anchor === "start" ? label.x : label.anchor === "end" ? label.x - width : label.x - width / 2;
for (const x of [left, left + width]) {
assert.ok(pointInPolygon({ x, y: label.y - 4 }, HOUSE_POLYGONS[house.house]!), `${label.text} stays in house ${house.house}`);
}
}
for (const [index, a] of labels.entries()) {
for (const b of labels.slice(index + 1)) {
assert.ok(Math.hypot(a.x - b.x, a.y - b.y) > 60, `${a.text} and ${b.text} are apart`);
}
}
const bodies = [...markup.matchAll(/aria-label="[^"]*第(\d+)宫"[^>]*><text class="chart-board-glyph" x="([\d.]+)" y="([\d.]+)" font-size="([\d.]+)"/g)];
assert.equal(bodies.length, 9);
for (const [, house, x, y, size] of bodies) {
const polygon = HOUSE_POLYGONS[Number(house)]!;
const s = Number(size);
// The glyph, and the degree written after it.
assert.ok(pointInPolygon({ x: Number(x) - s / 2, y: Number(y) - s / 2 }, polygon), `glyph in house ${house}`);
assert.ok(pointInPolygon({ x: Number(x) + s * 0.55 + 16, y: Number(y) - s / 2 }, polygon), `degree in house ${house}`);
}
});
test("the inspector shows the number with its band, and 问 AI only writes a question", async () => {
const view = await okView();
const source = boardSource(view, "D1")!;
const model = natalBoard(view);
const markup = renderToStaticMarkup(React.createElement(ChartInspector, {
view, transit: null, model, source,
selection: { kind: "body", key: "sun" },
onSelect: () => {}, onStep: () => {}, onAsk: () => {},
}));
const sun = view.shadbala!.find((row) => row.name === "Sun")!;
assert.ok(markup.includes(`达标 ${Math.round(sun.percent)}%`));
assert.match(markup, /chart-band is-strong/);
assert.match(markup, /问 AI:这颗星/);
assert.match(inspectorQuestion(view, model, { kind: "body", key: "sun" }), /^我的太阳在.+第\d+宫,对我意味着什么?$/);
const empty = renderToStaticMarkup(React.createElement(ChartInspector, {
view, transit: null, model, source, selection: null, onSelect: () => {}, onStep: () => {}, onAsk: () => {},
}));
assert.match(empty, /点盘上的星或宫看详情/);
assert.doesNotMatch(empty, /正在加载|spinner/i);
});
test("the 九曜 table marks the selected body and carries the six-strength column", async () => {
const view = await okView();
const markup = renderToStaticMarkup(React.createElement(ChartGrahaCard, { view, selection: { kind: "body", key: "moon" }, onSelect: () => {} }));
assert.equal((markup.match(/<tr class="is-selected"/g) ?? []).length, 1);
assert.match(markup, />六力</);
const compact = renderToStaticMarkup(React.createElement(ChartGrahaCard, { view, selection: null, onSelect: () => {}, compact: true }));
assert.doesNotMatch(compact, />六力</);
});
test("the summary names the strongest and weakest body from the engine's shadbala", async () => {
const view = await okView();
const glance = glanceOf(view, "2026-10-27");
const sorted = [...view.shadbala!].sort((a, b) => b.percent - a.percent);
assert.equal(glance.strongest?.name, sorted[0]!.name);
assert.equal(glance.weakest?.name, sorted.at(-1)!.name);
assert.equal(transitBySign(null).length, 0);
});
test("north or south is one site-wide choice, north by default", () => {
const values = new Map<string, string>();
const storage = { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => { values.set(key, value); } };
assert.equal(readStoredChartBoardStyle(storage), "north");
writeStoredChartBoardStyle("south", storage);
assert.equal(readStoredChartBoardStyle(storage), "south");
assert.equal(readStoredChartBoardStyle(null), "north");
});
test("the board stylesheet stays off the first paint of the chat page", () => {
const frame = readFileSync(new URL("../src/components/chat-chart-frame.tsx", import.meta.url), "utf8");
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
const route = readFileSync(new URL("../src/app/(app)/chart/page.tsx", import.meta.url), "utf8");
assert.match(frame, /import\("@\/components\/chart-board\/chart-board-styles"\)/);
assert.doesNotMatch(page, /chart-board/);
assert.match(route, /import "@\/components\/chart-board\/chart-board-styles";/);
});
test("the points layer keeps the engine's upagrahas, special lagnas, arudha padas and friendship", () => {
const layer = mapPoints(insight.chart_points);
assert.equal(layer.status, "ok");
if (layer.status !== "ok") return;
assert.equal(layer.upagrahas?.length, 11);
assert.deepEqual(layer.upagrahas!.slice(0, 5).map((point) => point.short), ["烟", "毁", "晕", "虹", "彗"]);
assert.equal(layer.special?.length, 10, "eight special lagnas plus Yogi and Avayogi");
assert.ok(layer.special!.every((point) => point.nakshatra && point.pada));
assert.deepEqual(layer.arudha!.map((point) => point.short), ["AL", "A2", "A3", "A4", "A5", "A6", "A7", "A8", "A9", "A10", "A11", "UL"]);
assert.ok(layer.arudha!.every((point, index) => point.sourceHouse === index + 1));
const sun = layer.friendship!.rows.find((row) => row.name === "Sun")!;
for (const target of sun.targets) {
if (target.compound === 2) assert.ok(target.natural === 1 && target.temporary === 1);
if (target.compound === -2) assert.ok(target.natural === -1 && target.temporary === -1);
}
assert.equal(mapPoints(null).status, "unavailable");
});
test("the ascendant's own BAV row and the shadbala sub-components come through", () => {
const layer = mapAshtakavarga(insight.ashtakavarga, insight.bhava_bala);
assert.equal(layer.status === "ok" && layer.lagnaBav?.reduce((a, b) => a + b, 0), 49);
const sun = mapShadbala(insightChart).find((row) => row.name === "Sun")!;
assert.ok(sun.sthanaParts && sun.kalaParts);
assert.equal(sun.kalaParts!.nathonnata, 60);
});
test("a picked arudha pada sits in its sign and links back to its own house", async () => {
const view = await okView();
const model = natalBoard(view);
const ul = pointsOf(view, "arudha")!.find((point) => point.key === "UL")!;
const relations = boardRelations(model, pointSelection(ul));
assert.equal(relations.focus, ul.house);
assert.deepEqual(relations.aspectLinks, ul.house === 12 ? [] : [12]);
assert.equal(stepPoint(view, "arudha", "UL", 1)?.key, "A1");
const markup = renderToStaticMarkup(React.createElement(ChartBoardSvg, {
model, style: "south", selection: pointSelection(ul), onSelect: () => {}, ariaLabel: "本命盘",
pointMarks: pointsOf(view, "arudha")!.map((point) => ({ key: point.key, short: point.short, label: point.label, sign: pointSelection(point).sign })),
}));
assert.equal((markup.match(/class="chart-board-mark( is-selected)?"/g) ?? []).length, 12);
assert.equal((markup.match(/chart-board-mark is-selected/g) ?? []).length, 1);
});
test("the inspector explains a point and 问 AI writes a question about it", async () => {
const view = await okView();
const model = natalBoard(view);
const gulika = pointsOf(view, "upagraha")!.find((point) => point.key === "Gulika")!;
const markup = renderToStaticMarkup(React.createElement(ChartInspector, {
view, transit: null, model, source: boardSource(view, "D1")!,
selection: pointSelection(gulika), onSelect: () => {}, onStep: () => {}, onAsk: () => {},
}));
assert.match(markup, /古利伽/);
assert.match(markup, /传统含义/);
assert.match(markup, /问 AI:这个点/);
assert.match(inspectorQuestion(view, model, pointSelection(gulika)), /^我的古利伽(Gulika)在.+第\d+宫,说明什么?$/);
});