Files
Jyotisha/frontend/tests/chart-view-route.test.ts
T

299 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/(app)/page.tsx", import.meta.url), "utf8");
// 原值:`../src/app/chart/page.tsx`
// 新值:`../src/app/(app)/chart/page.tsx`
// 原因:四个次级路由移进 `(secondary)` 路由组共享一份外壳(TASK-sidebar-unify D2)。
// 路由组括号不进 URL,`/chart` 一字未改;这里改的只是源码位置。
const chartPageSource = readFileSync(new URL("../src/app/(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);
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, /loadSubjectBirth/);
assert.doesNotMatch(serviceSource, /clientBirth|body\.profile/);
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");
// 原值: body.qizheng.status === "unavailable";新值: 响应不再带 qizheng;原因: TASK-chart-types D1
assert.equal("qizheng" in body, false);
assert.deepEqual(calls, ["/api/chart"]);
});
test("western stays 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 Dasha(kn_rao 变体)");
assert.equal(body.western.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);
// 原值: 全部 layer 时会打 /api/qizheng;新值: 任何情况都不打;原因: 七政层已删(TASK-chart-types D1)
assert.equal(calls.includes("/api/qizheng"), false);
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 });
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);
});
// 原值: 断言七政 ketuMode 取引擎字段;新值: 整条删除;原因: 七政层已从星盘页删除(TASK-chart-types D1),
// ketu_mode 的引擎侧合同仍由 tests/test_qizheng_api_productization.py 守住
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, /不能换算/);
// 原值: COORDINATE_BOUNDARY.qizheng 含「角宿」;新值: 该条已删,印度/西洋两句不再提七政;原因: TASK-chart-types D1
assert.equal("qizheng" in COORDINATE_BOUNDARY, false);
assert.doesNotMatch(COORDINATE_BOUNDARY.vedic + COORDINATE_BOUNDARY.western, /七政/);
});
test("page.tsx does not host the chart page", () => {
// Line-count freeze moved to tests/home-shell-growth-contract.test.ts
// (Home() useState / useRef caps + coarse line-count guardrail).
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"\)/);
});