Files
Jyotisha/frontend/tests/chart-view-route.test.ts
T
Jesse_ChenandClaude Fable 5.1 fb77c86585 test(ui): 侧栏只读模式、共享外壳与列表缓存的合同回归
新增 8 条:`sidebar-data-cache.test.ts`(命中不重拉 / 过期重拉一次 / 写操作后
拿到新标题并逐条锁住五个写路径 / 双账户不串 / 只存内存 / 401 清空)、
`sidebar-state.test.ts` +2(收起后重挂仍收起,含三种降级;移动端不读不写)、
`sidebar-contract.test.ts` +1(只读模式只少三样)、`chart-page-view.test.tsx` +1
(`(secondary)` layout 恰好挂一份只读侧栏,数据 hook 无写方法)。

改写 9 处既有断言,每处带「原值 / 新值 / 原因」三栏注释,均未削弱:
`window.location.assign(path)` → `<SidebarMenuLink href>` 并追加反向断言;
`onOpenReports` / `useRouter` 改成 doesNotMatch;`SecondaryShell` → `SecondaryHeader`;
导航顺序改在 `NAV_PAGES` 常量里量;两个 render 辅助改为裹 `SidebarProvider`
(provider 上移到 layout);三处源码路径跟随路由组移动。

测试总数 3391 → 3399,失败清单与基线逐条一致(47 条均为无 Docker 的既有缺口)。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-16 11:23:11 +00:00

316 lines
13 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");
// 原值:`../src/app/chart/page.tsx`
// 新值:`../src/app/(secondary)/chart/page.tsx`
// 原因:四个次级路由移进 `(secondary)` 路由组共享一份外壳(TASK-sidebar-unify D2)。
// 路由组括号不进 URL,`/chart` 一字未改;这里改的只是源码位置。
const chartPageSource = readFileSync(new URL("../src/app/(secondary)/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"\)/);
});