BUG-710: call getSevenGovernorsChart with ketuMode/siderealMode so calculation.ketu_mode is the engine school, not the request echo. BUG-711: stop forbidding /ephemeris in the sidebar contract. BUG-712: compare ephemeris event longitudes at 6 decimals and fail closed when the golden is missing.
173 lines
6.6 KiB
TypeScript
173 lines
6.6 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import { readFileSync } from "node:fs";
|
||
import test from "node:test";
|
||
|
||
import { assembleChartView } from "../src/lib/chart-view-load.ts";
|
||
import { chartViewOkSchema, chartViewResponseSchema } from "../src/lib/chart-view-contract.ts";
|
||
import { COORDINATE_BOUNDARY } from "../src/lib/chart-view-labels.ts";
|
||
|
||
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>;
|
||
chara: Record<string, unknown>;
|
||
western: Record<string, unknown>;
|
||
};
|
||
|
||
const routeSource = readFileSync(new URL("../src/app/api/chart-view/route.ts", import.meta.url), "utf8");
|
||
const serviceSource = readFileSync(new URL("../src/lib/chart-view-service.ts", import.meta.url), "utf8");
|
||
const loadSource = readFileSync(new URL("../src/lib/chart-view-load.ts", import.meta.url), "utf8");
|
||
const pageSource = readFileSync(new URL("../src/app/page.tsx", 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" as const,
|
||
};
|
||
|
||
function engine(overrides: Record<string, Record<string, unknown> | null> = {}) {
|
||
const calls: string[] = [];
|
||
const postEngine = async (path: string) => {
|
||
calls.push(path);
|
||
if (path in overrides) return overrides[path];
|
||
if (path === "/api/chart") return golden.chart;
|
||
if (path === "/api/varga_full") return golden.varga_full;
|
||
if (path === "/api/dasha/chara") return golden.chara;
|
||
if (path === "/api/western") return null;
|
||
if (path === "/api/qizheng") return null;
|
||
return null;
|
||
};
|
||
return { calls, postEngine };
|
||
}
|
||
|
||
test("the chart-view BFF never bills and never calls a model", () => {
|
||
for (const source of [routeSource, serviceSource, loadSource]) {
|
||
assert.doesNotMatch(source, /consultation-billing/);
|
||
assert.doesNotMatch(source, /@\/mastra/);
|
||
assert.doesNotMatch(source, /authorizeUsage|completeUsage|releaseUsage/);
|
||
}
|
||
assert.match(routeSource, /export async function GET/);
|
||
assert.match(serviceSource, /createServerSupabaseClient/);
|
||
assert.match(serviceSource, /ACCOUNT_BIRTH_SELECT/);
|
||
assert.match(loadSource, /\/api\/chart/);
|
||
assert.match(loadSource, /\/api\/dasha\/chara/);
|
||
assert.match(loadSource, /\/api\/varga_full/);
|
||
assert.match(loadSource, /planets/);
|
||
assert.match(loadSource, /ascendant/);
|
||
assert.match(loadSource, /houses/);
|
||
});
|
||
|
||
test("unauthenticated chart-view requests are 401", async () => {
|
||
const { calls, postEngine } = engine();
|
||
const result = await assembleChartView({
|
||
userId: null,
|
||
profile,
|
||
postEngine,
|
||
asOf: "2026-09-15",
|
||
});
|
||
assert.equal(result.httpStatus, 401);
|
||
assert.equal(result.body.status, "unauthenticated");
|
||
assert.equal(result.body.billed, false);
|
||
assert.equal(calls.length, 0);
|
||
});
|
||
|
||
test("an incomplete birth profile returns a structured message, not 500", async () => {
|
||
const { calls, postEngine } = engine();
|
||
const result = await assembleChartView({
|
||
userId: "user-1",
|
||
profile: null,
|
||
postEngine,
|
||
asOf: "2026-09-15",
|
||
});
|
||
assert.equal(result.httpStatus, 200);
|
||
assert.equal(result.body.status, "birth_profile_incomplete");
|
||
assert.notEqual(result.httpStatus, 500);
|
||
assert.match(result.body.message, /出生资料/);
|
||
assert.equal(calls.length, 0);
|
||
});
|
||
|
||
test("western and qizheng stay unavailable without failing the Indian tabs", async () => {
|
||
const { calls, postEngine } = engine();
|
||
const result = await assembleChartView({
|
||
userId: "user-1",
|
||
profile,
|
||
postEngine,
|
||
asOf: "2026-09-15",
|
||
});
|
||
assert.equal(result.httpStatus, 200);
|
||
const body = chartViewOkSchema.parse(result.body);
|
||
assert.equal(body.billed, false);
|
||
assert.ok(body.vedic.vargas.some((item) => item.id === "D1"));
|
||
assert.ok(body.vedic.planets.length >= 9);
|
||
assert.equal(body.dasha.vimshottari.method, "Vimshottari");
|
||
assert.equal(body.dasha.chara.title, "Chara Dasha(kn_rao 变体)");
|
||
assert.equal(body.western.status, "unavailable");
|
||
assert.equal(body.qizheng.status, "unavailable");
|
||
assert.ok(body.vedic.vargas.some((item) => item.id === "D9"));
|
||
assert.equal(calls.includes("/api/western"), true);
|
||
assert.equal(calls.includes("/api/qizheng"), true);
|
||
assert.doesNotMatch(calls.join(" "), /billing|mastra|consult/);
|
||
});
|
||
|
||
test("a real western engine packet lights the tropical tab without mixing sidereal longitudes", async () => {
|
||
const { postEngine } = engine({ "/api/western": golden.western, "/api/qizheng": null });
|
||
const result = await assembleChartView({
|
||
userId: "user-1",
|
||
profile,
|
||
postEngine,
|
||
asOf: "2026-09-15",
|
||
});
|
||
const body = chartViewOkSchema.parse(result.body);
|
||
assert.equal(body.western.status, "ok");
|
||
if (body.western.status !== "ok") throw new Error("expected western ok");
|
||
assert.equal(body.western.zodiac, "tropical");
|
||
assert.equal(body.western.houses.length, 12);
|
||
assert.match(body.western.boundary, /回归黄道/);
|
||
assert.notEqual(body.western.ascendantLongitude, body.vedic.planets[0]?.degreeInSign);
|
||
assert.equal(body.qizheng.status, "unavailable");
|
||
});
|
||
|
||
test("qizheng ketuMode follows the engine field, not a request echo", async () => {
|
||
const qizheng = {
|
||
coordinate_system: "qizheng_mansion_degrees_from_jiao",
|
||
calculation: { ketu_mode: "descending-node", engine_ketu_mode: "apogee" },
|
||
palaces: [{ name: "午", branch: "午", lifePalace: "命宫", mansions: ["星"], occupants: [] }],
|
||
bodies: { sun: { mansion: "奎", siderealLon: 177.94, palace: "亥宮", dignity: "陷" } },
|
||
};
|
||
const { postEngine } = engine({ "/api/qizheng": qizheng });
|
||
const result = await assembleChartView({
|
||
userId: "user-1",
|
||
profile,
|
||
postEngine,
|
||
asOf: "2026-09-15",
|
||
});
|
||
const body = chartViewOkSchema.parse(result.body);
|
||
assert.equal(body.qizheng.status, "ok");
|
||
if (body.qizheng.status !== "ok") throw new Error("expected qizheng ok");
|
||
assert.equal(body.qizheng.ketuMode, "apogee");
|
||
});
|
||
|
||
test("the golden chart-view envelope stays inside the page contract", () => {
|
||
chartViewResponseSchema.parse({
|
||
status: "birth_profile_incomplete",
|
||
billed: false,
|
||
message: "还没有可用来排盘的出生资料。",
|
||
});
|
||
assert.match(COORDINATE_BOUNDARY.vedic, /不能换算/);
|
||
assert.match(COORDINATE_BOUNDARY.western, /不能换算/);
|
||
assert.match(COORDINATE_BOUNDARY.qizheng, /角宿/);
|
||
});
|
||
|
||
test("page.tsx does not grow to host the chart page", () => {
|
||
assert.ok((pageSource.match(/\n/g) ?? []).length <= 1951);
|
||
assert.doesNotMatch(pageSource, /chart-page|ChartPageView|\/api\/chart-view/);
|
||
});
|