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; varga_full: Record; chara: Record; western: Record; }; 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 | 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 | null> = {}) { const calls: string[] = []; const bodies: Array<{ path: string; body: Record }> = []; 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(run: () => Promise): 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"\)/); });