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.
311 lines
12 KiB
TypeScript
311 lines
12 KiB
TypeScript
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 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"));
|
||
// 原值: 无 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"\)/);
|
||
});
|