Hide engine branding, gate supported divisional columns, align house cells, and add accessible symbol help. Reduce desktop chart width and stabilize the waiting slot. Validated with 78 browser checks and 87 focused frontend tests. Full suite retains 29 identical baseline loader failures; no new failures. Co-Authored-By: Claude Code <noreply@anthropic.com>
258 lines
18 KiB
TypeScript
258 lines
18 KiB
TypeScript
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);
|
||
assert.doesNotMatch(renderToStaticMarkup(React.createElement(VargaTable, { varga: d9 })), />庙旺<|>同位<|>不适用</);
|
||
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);
|
||
}));
|