Files
Jyotisha/frontend/tests/chart-page-hook.test.ts
T
jesse-uxGrok (agent loop)Cursor
d2edf8b7a8 fix(chart): 上升的状态格改成「/」
上升不是行星,本命、月亮盘和分盘的状态格不再写「不适用」。悬停或点一下说明原因。报告同一格写「/」,并在表下加中英文说明。

Co-Authored-By: Grok (agent loop)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 00:03:19 +08:00

261 lines
18 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 React, { useLayoutEffect } from "react";
import test from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { VargaTable } from "../src/components/chart-page/chart-vedic-tab.tsx";
import { ChartParamTable } from "../src/components/chart-page/chart-param-table.tsx";
import { useChartPage } from "../src/hooks/use-chart-page.ts";
import { bindCurrentSubjectAccount, resetSubjectCatalogForTests } from "../src/lib/current-subject.ts";
import { assembleChartView } from "../src/lib/chart-view-load.ts";
import type { ChartViewLayer, EngineCallResult } from "../src/lib/chart-view-engine.ts";
import type { ChartViewOk } from "../src/lib/chart-view-contract.ts";
import { CHART_VIEW_CLIENT_TIMEOUT_MS, CHART_VIEW_COPY } from "../src/lib/chart-view-labels.ts";
import { peekChartPage, pinChartSnapshotIdentity, prefetchSecondaryPage, resetSecondaryPageDataForTests, writeChartPage } from "../src/lib/secondary-page-data.ts";
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
const golden = JSON.parse(readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8"));
const bhavaGolden = JSON.parse(readFileSync(new URL("./fixtures/chart-view-bhava-golden.json", import.meta.url), "utf8"));
const transitGolden = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-chart-transit-2026-09-15.golden.json", import.meta.url), "utf8"));
const profile = { name: "Synthetic", date: "1990-06-15", time: "12:00", placeLabel: "Synthetic", latitude: 39.9042, longitude: 116.4074, timezoneOffset: 8, timezoneId: "Asia/Shanghai", ayanamsa: "raman", birthTimeStatus: "confirmed" };
async function packet(layer?: ChartViewLayer, failure?: EngineCallResult) {
// 原值: `/api/chart` 一律回本命 golden;新值: 带 cacheScope 的 `/api/chart`(行运盘)走 failure / 行运 golden
// 原因: 行运层也打 `/api/chart`,只按 cacheScope 区分(TASK-chart-types T4)
return assembleChartView({ userId: "synthetic", accountId: "synthetic", profileFingerprint: "fixture-v1", profile, asOf: "2026-09-15", layers: layer ? [layer] : [], postEngine: async (path, _body, cacheScope) => {
if (path === "/api/chart" && !cacheScope) return { status: "ok", payload: golden.chart };
if (failure) return failure;
const payload = path === "/api/varga_full" ? golden.varga_full : path === "/api/dasha/chara" ? golden.chara : path === "/api/chart" ? transitGolden : path === "/api/bhava_chalit" ? bhavaGolden.bhava_chalit : golden.western;
return { status: "ok", payload };
} });
}
function response(result: Awaited<ReturnType<typeof packet>>) { return new Response(JSON.stringify(result.body), { status: result.httpStatus }); }
function deferred<T>() { let resolve!: (value: T) => void; let reject!: (reason: unknown) => void; const promise = new Promise<T>((a, b) => { resolve = a; reject = b; }); return { promise, resolve, reject }; }
async function harness(run: (h: ReturnType<typeof createClientLifecycleHarness>, current: () => ReturnType<typeof useChartPage>, assigned: string[]) => Promise<void>) {
resetSecondaryPageDataForTests();
// 原值:未绑定账户,直接挂载;新值:隔离目录状态并绑定虚构账户。
// 原因:D3 请求必须等待目录校验完成;本组仍验证校验后的超时/401/429/并发,不绕过产品闸门。
resetSubjectCatalogForTests();
bindCurrentSubjectAccount("synthetic", []);
const originalFetch = globalThis.fetch;
const originalTimeout = AbortSignal.timeout;
const h = createClientLifecycleHarness();
let value!: ReturnType<typeof useChartPage>;
const assigned: string[] = [];
Object.assign(window, { location: { assign: (url: string) => { assert.equal(value.view?.status, "unauthenticated"); assigned.push(url); } } });
function Probe() { const state = useChartPage(); useLayoutEffect(() => { value = state; }); return null; }
const mount = h.render.bind(h);
h.render = () => mount(React.createElement(Probe));
try { await run(h, () => value, assigned); assert.deepEqual(h.errors, []); }
// 原值:只清图盘缓存;新值:卸载后同时清目录状态;原因:不让上一例账户 ready 泄漏到下一例。
finally { await h.close(); globalThis.fetch = originalFetch; AbortSignal.timeout = originalTimeout; resetSecondaryPageDataForTests(); resetSubjectCatalogForTests(); }
}
test("chart first request times out with client_timeout and timedOut copy", async () => harness(async (h, state) => {
const deadline = new AbortController();
AbortSignal.timeout = (ms) => { assert.equal(ms, CHART_VIEW_CLIENT_TIMEOUT_MS); return deadline.signal; };
globalThis.fetch = async (_url, init) => new Promise((_resolve, reject) => init?.signal?.addEventListener("abort", () => reject(init.signal!.reason), { once: true }));
await h.render(null);
assert.equal(state().view, null);
await h.update(() => deadline.abort(new DOMException("synthetic", "TimeoutError")));
assert.equal(state().view?.status, "client_timeout");
const view = state().view;
assert.equal(view?.status !== "ok" && view?.message, CHART_VIEW_COPY.timedOut);
}));
test("sidebar prefetch shares the same bounded request with the mounted hook", async () => harness(async (h, state) => {
const deadline = new AbortController(); let calls = 0;
AbortSignal.timeout = (ms) => { assert.equal(ms, 25_000); return deadline.signal; };
globalThis.fetch = async (_url, init) => { calls++; return new Promise((_resolve, reject) => init?.signal?.addEventListener("abort", () => reject(init.signal!.reason))); };
prefetchSecondaryPage("/chart");
await h.render(null);
await h.update(() => deadline.abort(new DOMException("synthetic", "TimeoutError")));
assert.equal(calls, 1); assert.equal(state().view?.status, "client_timeout");
}));
test("chart cache populated during failed fetch is painted instead of remaining null", async () => harness(async (h, state) => {
const request = deferred<Response>(); globalThis.fetch = () => request.promise;
await h.render(null);
const result = await packet();
writeChartPage({ kind: "view", view: result.body });
await h.update(() => request.reject(new TypeError("synthetic network failure")));
assert.deepEqual(state().view, result.body);
}));
for (const cached of [false, true]) test(`chart 401 ${cached ? "cache" : "response"} paints unauthenticated before redirect`, async () => harness(async (h, state, assigned) => {
globalThis.fetch = async () => new Response("{}", { status: 401 });
if (cached) writeChartPage({ kind: "unauthenticated" });
await h.render(null);
assert.equal(state().view?.status, "unauthenticated"); assert.deepEqual(assigned, ["/login"]);
}));
// 原值: ["varga", "chara", "western", "qizheng"];新值: 去掉 qizheng、加 bhava 与 transit
// 原因: 七政懒加载层已删(TASK-chart-types D1),行运层(T4)与 Bhava 层(T5)新增
for (const layer of ["varga", "chara", "western", "bhava", "transit"] as const) test(`real assemble to fetch to hook preserves ${layer} engine 429`, async () => harness(async (h, state) => {
const initial = await packet();
const failed = await packet(layer, { status: "busy", path: "/api/synthetic", elapsedMs: 1, httpStatus: 429 });
globalThis.fetch = async (url) => response(String(url).includes("layers=") ? failed : initial);
await h.render(null);
await h.update(() => state().requestLayer(layer));
assert.equal(state().layerFailures.get(layer), "engine_busy");
assert.equal(state().pendingLayers.has(layer), false);
assert.deepEqual(state().view, initial.body);
}));
test("layer catch fails unavailable and a retry clears failure while pending then succeeds", async () => harness(async (h, state) => {
const initial = await packet(); const good = await packet("varga"); const retry = deferred<Response>(); let calls = 0;
globalThis.fetch = async (url) => {
if (!String(url).includes("layers=")) return response(initial);
calls++; if (calls === 1) throw new TypeError("synthetic"); return retry.promise;
};
await h.render(null); await h.update(() => state().requestLayer("varga"));
assert.equal(state().layerFailures.get("varga"), "unavailable");
await h.update(() => { state().requestLayer("varga"); state().requestLayer("varga"); });
assert.equal(state().layerFailures.has("varga"), false); assert.equal(state().pendingLayers.has("varga"), true); assert.equal(calls, 2);
await h.update(() => retry.resolve(response(good)));
assert.equal(state().pendingLayers.has("varga"), false); assert.equal(state().layerFailures.size, 0);
assert.ok((state().view as ChartViewOk).vedic.vargas.some((v) => v.id === "D9"));
}));
test("BFF rate_limited stays distinct from engine_busy", async () => harness(async (h, state) => {
const initial = await packet(); globalThis.fetch = async (url) => String(url).includes("layers=") ? new Response(JSON.stringify({ status: "rate_limited", billed: false, message: CHART_VIEW_COPY.rateLimited }), { status: 429 }) : response(initial);
await h.render(null); await h.update(() => state().requestLayer("varga"));
assert.equal(state().layerFailures.get("varga"), "rate_limited");
}));
test("layer timeout while reading response JSON remains client_timeout", async () => harness(async (h, state) => {
const initial = await packet(); const deadline = new AbortController();
globalThis.fetch = async (url) => String(url).includes("layers=") ? { status: 200, json: () => new Promise((_resolve, reject) => deadline.signal.addEventListener("abort", () => reject(new DOMException("synthetic", "AbortError")))) } as Response : response(initial);
await h.render(null);
AbortSignal.timeout = () => deadline.signal;
await h.update(() => state().requestLayer("varga"));
await h.update(() => deadline.abort(new DOMException("synthetic", "TimeoutError")));
assert.equal(state().layerFailures.get("varga"), "client_timeout");
}));
test("out-of-order successful layers preserve previously loaded data", async () => harness(async (h, state) => {
const initial = await packet(); const varga = await packet("varga"); const western = await packet("western"); const v = deferred<Response>(); const w = deferred<Response>();
globalThis.fetch = async (url) => String(url).includes("varga") ? v.promise : String(url).includes("western") ? w.promise : response(initial);
await h.render(null); await h.update(() => { state().requestLayer("varga"); state().requestLayer("western"); });
await h.update(() => w.resolve(response(western))); await h.update(() => v.resolve(response(varga)));
const view = state().view as ChartViewOk;
assert.equal(view.western.status, "ok"); assert.ok(view.vedic.vargas.some((x) => x.id === "D9"));
}));
test("late warm D1 refresh preserves the layer that completed after mounting", async () => harness(async (h, state) => {
const initial = await packet(); const western = await packet("western"); const refresh = deferred<Response>();
writeChartPage({ kind: "view", view: initial.body });
globalThis.fetch = async (url) => String(url).includes("layers=") ? response(western) : refresh.promise;
await h.render(null); await h.update(() => state().requestLayer("western"));
assert.equal((state().view as ChartViewOk).western.status, "ok");
await h.update(() => refresh.resolve(response(initial)));
assert.equal((state().view as ChartViewOk).western.status, "ok");
}));
test("the transit layer merges beside varga and survives a late warm D1 refresh", async () => harness(async (h, state) => {
const initial = await packet(); const varga = await packet("varga"); const transit = await packet("transit"); const refresh = deferred<Response>();
writeChartPage({ kind: "view", view: initial.body });
globalThis.fetch = async (url) => String(url).includes("layers=transit") ? response(transit) : String(url).includes("layers=varga") ? response(varga) : refresh.promise;
await h.render(null);
await h.update(() => state().requestLayer("transit"));
await h.update(() => state().requestLayer("varga"));
let view = state().view as ChartViewOk;
assert.equal(view.transit.status, "ok"); assert.ok(view.vedic.vargas.some((x) => x.id === "D60"));
await h.update(() => refresh.resolve(response(initial)));
view = state().view as ChartViewOk;
assert.equal(view.transit.status, "ok", "a D1-only refresh does not erase the transit layer");
assert.ok(view.vedic.vargas.some((x) => x.id === "D60"));
}));
test("old capability-less module cache stays hidden through direct reads and layer merges until refreshed", async () => harness(async (h, state) => {
const initial = await packet();
const currentVargas = await packet("varga");
const western = await packet("western");
const legacy = structuredClone(currentVargas.body) as ChartViewOk;
Object.assign(legacy.profile, { engineName: "Swiss Ephemeris(本站)" });
for (const varga of legacy.vedic.vargas) {
delete varga.supportsDignity;
delete varga.supportsVargottama;
}
pinChartSnapshotIdentity({ accountId: "synthetic", fingerprint: "fixture-v1" });
writeChartPage({ kind: "view", view: legacy });
const refresh = deferred<Response>();
globalThis.fetch = async (url) => String(url).includes("layers=western") ? response(western)
: String(url).includes("layers=varga") ? response(currentVargas) : refresh.promise;
const assertLegacyHidden = () => {
const view = state().view as ChartViewOk;
const d9 = view.vedic.vargas.find((item) => item.id === "D9")!;
assert.equal(d9.supportsDignity, undefined);
assert.equal(d9.supportsVargottama, undefined);
// 原值:/>庙旺<|>同位<|>不适用</
// 新值:/>庙旺<|>同位<|chart-page-asc-status/
// 原因:issue #87。上升状态格不再写「不适用」。旧缓存整列仍须隐藏,保护改认这个 class。
assert.doesNotMatch(renderToStaticMarkup(React.createElement(VargaTable, { varga: d9 })), />庙旺<|>同位<|chart-page-asc-status/);
assert.doesNotMatch(renderToStaticMarkup(React.createElement(ChartParamTable, { view })), /引擎|Swiss Ephemeris/);
};
await h.render(null);
assertLegacyHidden(); // Cache read bypasses schema parsing.
await h.update(() => state().requestLayer("western"));
assert.equal((state().view as ChartViewOk).western.status, "ok");
assertLegacyHidden(); // Unrelated layer merge retains the old varga object.
await h.update(() => refresh.resolve(response(initial)));
assertLegacyHidden(); // Late D1 refresh retains already loaded vargas too.
await h.update(() => state().requestLayer("varga"));
const updated = state().view as ChartViewOk;
const d9 = updated.vedic.vargas.find((item) => item.id === "D9")!;
assert.equal(d9.supportsDignity, true);
assert.equal(d9.supportsVargottama, true);
assert.match(renderToStaticMarkup(React.createElement(VargaTable, { varga: d9 })), />庙旺<[\s\S]*>同位</);
assert.equal(updated.western.status, "ok");
}));
test("invalid capability types fail network schema validation without replacing a valid view", async () => harness(async (h, state) => {
const initial = await packet();
const invalid = await packet("varga");
const body = structuredClone(invalid.body) as ChartViewOk;
const d9 = body.vedic.vargas.find((item) => item.id === "D9")!;
Object.assign(d9, { supportsVargottama: "true" });
globalThis.fetch = async (url) => String(url).includes("layers=") ? response({ ...invalid, body }) : response(initial);
await h.render(null);
await h.update(() => state().requestLayer("varga"));
assert.equal(state().layerFailures.get("varga"), "response_schema_failure");
assert.deepEqual(state().view, initial.body);
}));
test("HTTP 429 without a valid body remains engine_busy", async () => harness(async (h, state) => {
const initial = await packet(); globalThis.fetch = async (url) => String(url).includes("layers=") ? new Response("busy", { status: 429 }) : response(initial);
await h.render(null); await h.update(() => state().requestLayer("western"));
assert.equal(state().layerFailures.get("western"), "engine_busy");
}));
test("an obsolete first fetch failure cannot overwrite a new profile snapshot", async () => harness(async (h, state) => {
const old = deferred<Response>(); const initial = await packet();
const fresh = { ...initial, body: { ...initial.body, profileFingerprint: "fixture-v2" } };
let calls = 0;
globalThis.fetch = async () => ++calls === 1 ? old.promise : response(fresh);
await h.render(null);
pinChartSnapshotIdentity({ accountId: "synthetic", fingerprint: "fixture-v2" });
await h.update(() => old.reject(new DOMException("synthetic", "TimeoutError")));
assert.equal(state().view?.profileFingerprint, "fixture-v2");
assert.equal(peekChartPage()?.kind === "view" && peekChartPage()?.kind, "view");
assert.equal(calls, 2);
}));
test("layer 401 paints failure before redirect and never becomes a layer wait", async () => harness(async (h, state, assigned) => {
const initial = await packet(); globalThis.fetch = async (url) => String(url).includes("layers=") ? new Response(null, { status: 401 }) : response(initial);
await h.render(null); await h.update(() => state().requestLayer("western"));
assert.deepEqual(assigned, ["/login"]); assert.equal(state().view?.status, "unauthenticated");
assert.equal(state().pendingLayers.size, 0);
}));
test("profile pin invalidation ignores an older layer completion", async () => harness(async (h, state) => {
const initial = await packet(); const old = await packet("varga"); const request = deferred<Response>();
globalThis.fetch = async (url) => String(url).includes("layers=") ? request.promise : response(initial);
await h.render(null); await h.update(() => state().requestLayer("varga"));
pinChartSnapshotIdentity({ accountId: "synthetic", fingerprint: "fixture-v2" });
const fresh = { ...initial.body, profileFingerprint: "fixture-v2" };
writeChartPage({ kind: "view", view: fresh });
await h.update(() => request.resolve(response(old)));
assert.deepEqual(peekChartPage(), { kind: "view", view: fresh });
assert.equal(state().layerFailures.size, 0);
}));