Files
Jyotisha/frontend/tests/chart-page-hook.test.ts
T

203 lines
14 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 { 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("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);
}));