Files
Jyotisha/frontend/tests/chart-view-route.test.ts
T
jesse-ux 62be52dacf
Independent Staging Quality Gate / validate (push) Canceled after 2m16s
Independent Staging Quality Gate / publish (push) Canceled after 0s
fix(chart): skip VedAstro on chart-page first paint
The natal /api/chart request from the chart page now sends skip_vedastro_main_entry_overview, matching the ephemeris page. Mapper and contract never read that evidence. BUG-718 recurs from BUG-161. Tests lock both foreground natal paths.
2026-09-16 00:22:00 +08:00

311 lines
12 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 test from "node:test";
import { assembleChartView, type ChartViewEnginePost } from "../src/lib/chart-view-load.ts";
import { chartViewOkSchema, chartViewResponseSchema } from "../src/lib/chart-view-contract.ts";
import { CHART_VIEW_LAYERS, type EngineCallResult } from "../src/lib/chart-view-engine.ts";
import { CHART_VIEW_COPY, 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 chartPageSource = readFileSync(new URL("../src/app/chart/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 isEngineCallResult(value: object): value is EngineCallResult {
if (!("status" in value)) return false;
const status = (value as { status?: unknown }).status;
if (status === "ok") return "payload" in value && typeof (value as { payload?: unknown }).payload === "object";
return status === "busy" || status === "http_error" || status === "timeout" || status === "bad_payload";
}
function asResult(path: string, value: EngineCallResult | Record<string, unknown> | null): EngineCallResult {
if (value === null) {
return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 };
}
if (isEngineCallResult(value)) return value;
return { status: "ok", payload: value };
}
function engine(overrides: Record<string, EngineCallResult | Record<string, unknown> | null> = {}) {
const calls: string[] = [];
const bodies: Array<{ path: string; body: Record<string, unknown> }> = [];
const postEngine: ChartViewEnginePost = async (path, body) => {
calls.push(path);
bodies.push({ path, body });
if (path in overrides) return asResult(path, overrides[path]!);
if (path === "/api/chart") return asResult(path, golden.chart);
if (path === "/api/varga_full") return asResult(path, golden.varga_full);
if (path === "/api/dasha/chara") return asResult(path, golden.chara);
if (path === "/api/western") return asResult(path, null);
if (path === "/api/qizheng") return asResult(path, null);
return asResult(path, null);
};
return { calls, bodies, postEngine };
}
function captureWarnings<T>(run: () => Promise<T>): Promise<{ result: T; warnings: unknown[] }> {
const warnings: unknown[] = [];
const original = console.warn;
console.warn = (...args: unknown[]) => {
warnings.push(args);
};
return run()
.then((result) => ({ result, warnings }))
.finally(() => {
console.warn = original;
});
}
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/);
assert.match(serviceSource, /CHART_VIEW_ENGINE_TIMEOUT_MS/);
assert.doesNotMatch(serviceSource, /45_000/);
assert.doesNotMatch(loadSource, /catch\s*\{\s*/);
assert.doesNotMatch(serviceSource, /catch\s*\{\s*return null/);
});
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("foreground chart-view natal requests skip VedAstro overview", async () => {
const { bodies, postEngine } = engine();
await assembleChartView({
userId: "user-1",
profile,
postEngine,
asOf: "2026-09-15",
});
const natal = bodies.filter((item) => item.path === "/api/chart");
assert.equal(natal.length, 1);
assert.equal(natal[0]?.body.skip_vedastro_main_entry_overview, true);
assert.match(loadSource, /skip_vedastro_main_entry_overview:\s*true/);
});
test("opening the natal chart does not call follow-up engine paths", 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.equal(body.western.status, "unavailable");
assert.equal(body.qizheng.status, "unavailable");
assert.deepEqual(calls, ["/api/chart"]);
});
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",
layers: CHART_VIEW_LAYERS,
});
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 Dashakn_rao 变体)");
assert.equal(body.western.status, "unavailable");
assert.equal(body.qizheng.status, "unavailable");
assert.ok(body.vedic.vargas.some((item) => item.id === "D9"));
// 原值: 无 layers 时也会打 /api/western 与 /api/qizheng
// 新值: 只有请求对应 layer 才打
// 原因: BUG-717 开页不得占满重计算配额;西洋/七政改按需
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",
layers: ["western"],
});
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",
layers: ["qizheng"],
});
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("busy vs other natal engine failures split copy and each leave a log line", async () => {
const stubs: Array<{ status: EngineCallResult["status"]; httpStatus?: number; errorName?: string }> = [
{ status: "busy", httpStatus: 429 },
{ status: "http_error", httpStatus: 500 },
{ status: "timeout", errorName: "TimeoutError" },
{ status: "bad_payload", httpStatus: 200 },
];
for (const stub of stubs) {
const { result, warnings } = await captureWarnings(() => assembleChartView({
userId: "user-1",
profile,
asOf: "2026-09-15",
postEngine: async (path) => ({
status: stub.status === "ok" ? "http_error" : stub.status,
path,
elapsedMs: 22,
httpStatus: stub.httpStatus,
errorName: stub.errorName,
}),
}));
assert.equal(result.body.status, "chart_unavailable");
assert.equal(warnings.length, 1);
const line = JSON.stringify(warnings[0]);
assert.match(line, new RegExp(stub.status === "ok" ? "http_error" : stub.status));
assert.doesNotMatch(line, /示例|1990|39\.9042/);
if (stub.status === "busy") {
assert.equal(result.body.message, CHART_VIEW_COPY.busy);
} else {
assert.equal(result.body.message, CHART_VIEW_COPY.unavailable);
assert.doesNotMatch(result.body.message, /过一会儿再打开/);
}
}
});
test("mapper failures log the error name and do not swallow with an empty catch", async () => {
const { result, warnings } = await captureWarnings(() => assembleChartView({
userId: "user-1",
profile,
asOf: "2026-09-15",
postEngine: async (path) => {
if (path !== "/api/chart") return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 };
return {
status: "ok",
payload: {
success: true,
planets: { Sun: { sign: "Aries", lon: 10, degree_in_sign: 10 } },
ascendant: {},
},
};
},
}));
assert.equal(result.body.status, "chart_unavailable");
assert.equal(result.body.message, CHART_VIEW_COPY.unavailable);
assert.match(JSON.stringify(warnings), /chart_view_assemble_failed/);
assert.doesNotMatch(loadSource, /catch\s*\{\s*\r?\n\s*return \{/);
});
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/);
});
test("the chart route renders a shell and does not block the document on engine calls", () => {
assert.doesNotMatch(chartPageSource, /force-dynamic/);
assert.doesNotMatch(chartPageSource, /loadChartView/);
assert.match(chartPageSource, /ChartPageRoute/);
assert.match(routeSource, /parseChartViewLayers/);
assert.match(routeSource, /searchParams\.get\("layers"\)/);
});