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>) { return new Response(JSON.stringify(result.body), { status: result.httpStatus }); } function deferred() { let resolve!: (value: T) => void; let reject!: (reason: unknown) => void; const promise = new Promise((a, b) => { resolve = a; reject = b; }); return { promise, resolve, reject }; } async function harness(run: (h: ReturnType, current: () => ReturnType, assigned: string[]) => Promise) { resetSecondaryPageDataForTests(); // 原值:未绑定账户,直接挂载;新值:隔离目录状态并绑定虚构账户。 // 原因:D3 请求必须等待目录校验完成;本组仍验证校验后的超时/401/429/并发,不绕过产品闸门。 resetSubjectCatalogForTests(); bindCurrentSubjectAccount("synthetic", []); const originalFetch = globalThis.fetch; const originalTimeout = AbortSignal.timeout; const h = createClientLifecycleHarness(); let value!: ReturnType; 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(); 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(); 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(); const w = deferred(); 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(); 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(); 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(); 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); assert.doesNotMatch(renderToStaticMarkup(React.createElement(VargaTable, { varga: d9 })), />庙旺<|>同位<|>不适用 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]*>同位 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(); 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(); 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); }));