Files
Jyotisha/frontend/tests/chart-view-route.test.ts
T
jesse-ux 3b17c1b242
Independent Staging Quality Gate / validate (push) Failing after 6m21s
Independent Staging Quality Gate / publish (push) Skipped
test(freeze): 增长冻结改盯耦合,行数降为粗护栏
Home() useState/useRef 与 JyotishAPIHandler 类方法/__new__ 不得增长。行数改为开工实测基线加余量。产品授权删除参数式 hook 零 React hook 规则。
2026-09-16 08:02:43 +08:00

312 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 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"\)/);
});