feat(dasha): 大运按轨道合并,刷新保留已到的轨
Chara 和 Yogini 各换各的一段。空的小运列表只让这一层失败,已经在页上的另一轨不动。 Co-Authored-By: Grok (agent loop) Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Grok (agent loop)
Cursor
parent
7a296c7348
commit
5ebbd57b04
@@ -6,7 +6,7 @@ import { flushSync } from "react-dom";
|
||||
import { fetchChartView } from "@/lib/chart-view-client";
|
||||
import { readCurrentSubjectId, useCurrentSubject, useCurrentSubjectReady } from "@/lib/current-subject";
|
||||
import type { ChartViewFailureStatus, ChartViewOk, ChartViewResponse } from "@/lib/chart-view-contract";
|
||||
import type { ChartViewLayer } from "@/lib/chart-view-engine";
|
||||
import { dashaSystemByLayer, type ChartViewLayer } from "@/lib/chart-view-engine";
|
||||
import { chartClientFailureStatus, chartViewFailureResponse } from "@/lib/chart-view-failure";
|
||||
import { CHART_VIEW_CLIENT_TIMEOUT_MS, CHART_VIEW_COPY, VARGA_CHIP_ORDER } from "@/lib/chart-view-labels";
|
||||
import {
|
||||
@@ -42,11 +42,12 @@ function mergeLayer(current: ChartViewOk, incoming: ChartViewOk, layer: ChartVie
|
||||
if (current.accountId !== incoming.accountId || current.profileFingerprint !== incoming.profileFingerprint) return incoming;
|
||||
const layerFailures = { ...current.layerFailures };
|
||||
delete layerFailures[layer];
|
||||
const dashaSystem = dashaSystemByLayer(layer);
|
||||
return {
|
||||
...current,
|
||||
layerFailures,
|
||||
...(layer === "varga" ? { vedic: incoming.vedic } : {}),
|
||||
...(layer === "chara" ? { dasha: incoming.dasha } : {}),
|
||||
...(dashaSystem ? { dasha: { ...current.dasha, [dashaSystem.track]: incoming.dasha[dashaSystem.track] } } : {}),
|
||||
...(layer === "western" ? { western: incoming.western } : {}),
|
||||
...(layer === "bhava" ? { bhava: incoming.bhava } : {}),
|
||||
...(layer === "transit" ? { transit: incoming.transit } : {}),
|
||||
@@ -139,9 +140,11 @@ export function useChartPage() {
|
||||
}
|
||||
const failure = body.layerFailures?.[layer];
|
||||
if (failure) { fail(failure); return; }
|
||||
const dashaSystem = dashaSystemByLayer(layer);
|
||||
const missing = layer === "varga" ? !VARGA_CHIP_ORDER.every((division) => body.vedic.vargas.some((varga) => varga.id === `D${division}`))
|
||||
: layer === "chara" ? body.dasha.chara.periods.length === 0
|
||||
: body[layer].status !== "ok";
|
||||
: dashaSystem ? body.dasha[dashaSystem.track].periods.length === 0
|
||||
: layer === "western" || layer === "bhava" || layer === "transit" ? body[layer].status !== "ok"
|
||||
: true;
|
||||
if (missing) { fail("unavailable"); return; }
|
||||
const current = peekChartPage();
|
||||
const merged = current?.kind === "view" && current.view.status === "ok"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { fetchChartView } from "@/lib/chart-view-client";
|
||||
import { currentSubjectCatalogReady, readCurrentSubjectAccountId, readCurrentSubjectId } from "@/lib/current-subject";
|
||||
import type { ChartViewResponse } from "@/lib/chart-view-contract";
|
||||
import type { ChartViewOk, ChartViewResponse } from "@/lib/chart-view-contract";
|
||||
import { CHART_DASHA_SYSTEMS } from "@/lib/chart-view-engine";
|
||||
import { chartClientFailureStatus, chartViewFailureResponse } from "@/lib/chart-view-failure";
|
||||
import { CHART_VIEW_CLIENT_TIMEOUT_MS, CHART_VIEW_COPY } from "@/lib/chart-view-labels";
|
||||
import { parseEphemerisOkResponse, type EphemerisOkResponse } from "@/lib/ephemeris-contract";
|
||||
@@ -185,6 +186,15 @@ function chartSnapshotMatchesPin(snapshot: ChartPageSnapshot): boolean {
|
||||
return sameChartIdentity(identity, pinnedChartIdentity);
|
||||
}
|
||||
|
||||
function dashaKeepingArrivedTracks(prior: ChartViewOk["dasha"], next: ChartViewOk["dasha"]): ChartViewOk["dasha"] {
|
||||
const dasha = { ...next };
|
||||
for (const system of CHART_DASHA_SYSTEMS) {
|
||||
const track = prior[system.track];
|
||||
if (track && track.periods.length > 0) dasha[system.track] = track;
|
||||
}
|
||||
return dasha;
|
||||
}
|
||||
|
||||
async function loadChartSnapshot(started: number, signal: AbortSignal): Promise<ChartPageSnapshot> {
|
||||
const subjectId = readCurrentSubjectId();
|
||||
if (!pinnedChartIdentity || subjectOf(pinnedChartIdentity) !== subjectId) {
|
||||
@@ -214,7 +224,7 @@ async function loadChartSnapshot(started: number, signal: AbortSignal): Promise<
|
||||
snapshot = { kind: "view", view: {
|
||||
...snapshot.view,
|
||||
...(prior.vedic.vargas.length > 1 ? { vedic: prior.vedic } : {}),
|
||||
dasha: { ...snapshot.view.dasha, ...(prior.dasha.chara.periods.length ? { chara: prior.dasha.chara } : {}) },
|
||||
dasha: dashaKeepingArrivedTracks(prior.dasha, snapshot.view.dasha),
|
||||
...(prior.western.status === "ok" ? { western: prior.western } : {}),
|
||||
...(prior.bhava.status === "ok" ? { bhava: prior.bhava } : {}),
|
||||
...(prior.transit.status === "ok" ? { transit: prior.transit } : {}),
|
||||
|
||||
@@ -17,13 +17,22 @@ import { createClientLifecycleHarness } from "./react-client-lifecycle-test-supp
|
||||
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 yoginiGolden = JSON.parse(readFileSync(new URL("./fixtures/yogini-dasha-api-1990-01-01.golden.json", import.meta.url), "utf8")).response;
|
||||
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) => {
|
||||
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;
|
||||
// 原值: `/api/dasha` 没有分支,落到西洋包
|
||||
// 新值: 按 body.dasha 分派;yogini 用真实接口响应,其它键回空 periods
|
||||
// 原因: Chara 与 Yogini 都会打大运接口,不能按 path 共用一份包(issue #88)
|
||||
if (path === "/api/dasha") {
|
||||
return body.dasha === "yogini"
|
||||
? { status: "ok", payload: yoginiGolden }
|
||||
: { status: "ok", payload: { periods: [] } };
|
||||
}
|
||||
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 };
|
||||
} });
|
||||
@@ -88,9 +97,10 @@ for (const cached of [false, true]) test(`chart 401 ${cached ? "cache" : "respon
|
||||
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) => {
|
||||
// 原值: ["varga", "chara", "western", "bhava", "transit"]
|
||||
// 新值: ["varga", "chara", "yogini", "western", "bhava", "transit"]
|
||||
// 原因: Yogini 是按需大运层,429 只记在这一层。更早一次是去掉 qizheng、加上 bhava 与 transit(TASK-chart-types)
|
||||
for (const layer of ["varga", "chara", "yogini", "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);
|
||||
@@ -258,3 +268,109 @@ test("profile pin invalidation ignores an older layer completion", async () => h
|
||||
assert.deepEqual(peekChartPage(), { kind: "view", view: fresh });
|
||||
assert.equal(state().layerFailures.size, 0);
|
||||
}));
|
||||
|
||||
test("chara and yogini replace only their own dasha track", async () => harness(async (h, state) => {
|
||||
const initial = await packet();
|
||||
const chara = await packet("chara");
|
||||
const yogini = await packet("yogini");
|
||||
writeChartPage({ kind: "view", view: initial.body });
|
||||
globalThis.fetch = async (url) => {
|
||||
const text = String(url);
|
||||
if (text.includes("layers=yogini")) return response(yogini);
|
||||
if (text.includes("layers=chara")) return response(chara);
|
||||
return response(initial);
|
||||
};
|
||||
await h.render(null);
|
||||
await h.update(() => state().requestLayer("chara"));
|
||||
let view = state().view as ChartViewOk;
|
||||
const charaPeriods = view.dasha.chara.periods.length;
|
||||
assert.ok(charaPeriods > 0);
|
||||
assert.equal(view.dasha.yogini.periods.length, 0);
|
||||
await h.update(() => state().requestLayer("yogini"));
|
||||
view = state().view as ChartViewOk;
|
||||
assert.equal(view.dasha.chara.periods.length, charaPeriods);
|
||||
assert.ok(view.dasha.yogini.periods.length > 0);
|
||||
}));
|
||||
|
||||
test("yogini then chara keeps the yogini track", async () => harness(async (h, state) => {
|
||||
const initial = await packet();
|
||||
const chara = await packet("chara");
|
||||
const yogini = await packet("yogini");
|
||||
writeChartPage({ kind: "view", view: initial.body });
|
||||
globalThis.fetch = async (url) => {
|
||||
const text = String(url);
|
||||
if (text.includes("layers=yogini")) return response(yogini);
|
||||
if (text.includes("layers=chara")) return response(chara);
|
||||
return response(initial);
|
||||
};
|
||||
await h.render(null);
|
||||
await h.update(() => state().requestLayer("yogini"));
|
||||
const yoginiPeriods = (state().view as ChartViewOk).dasha.yogini.periods.length;
|
||||
assert.ok(yoginiPeriods > 0);
|
||||
await h.update(() => state().requestLayer("chara"));
|
||||
const view = state().view as ChartViewOk;
|
||||
assert.equal(view.dasha.yogini.periods.length, yoginiPeriods);
|
||||
assert.ok(view.dasha.chara.periods.length > 0);
|
||||
}));
|
||||
|
||||
test("yogini 429 leaves a loaded chara track in place", async () => harness(async (h, state) => {
|
||||
const initial = await packet();
|
||||
const chara = await packet("chara");
|
||||
const failed = await packet("yogini", { status: "busy", path: "/api/dasha", elapsedMs: 1, httpStatus: 429 });
|
||||
writeChartPage({ kind: "view", view: initial.body });
|
||||
globalThis.fetch = async (url) => {
|
||||
const text = String(url);
|
||||
if (text.includes("layers=yogini")) return response(failed);
|
||||
if (text.includes("layers=chara")) return response(chara);
|
||||
return response(initial);
|
||||
};
|
||||
await h.render(null);
|
||||
await h.update(() => state().requestLayer("chara"));
|
||||
const charaPeriods = (state().view as ChartViewOk).dasha.chara.periods.length;
|
||||
await h.update(() => state().requestLayer("yogini"));
|
||||
const view = state().view as ChartViewOk;
|
||||
assert.equal(state().layerFailures.get("yogini"), "engine_busy");
|
||||
assert.equal(state().layerFailures.has("chara"), false);
|
||||
assert.equal(view.dasha.chara.periods.length, charaPeriods);
|
||||
}));
|
||||
|
||||
test("a warm D1 refresh keeps an arrived yogini track", async () => harness(async (h, state) => {
|
||||
const initial = await packet();
|
||||
const yogini = await packet("yogini");
|
||||
const refresh = deferred<Response>();
|
||||
writeChartPage({ kind: "view", view: initial.body });
|
||||
globalThis.fetch = async (url) => String(url).includes("layers=yogini") ? response(yogini) : refresh.promise;
|
||||
await h.render(null);
|
||||
await h.update(() => state().requestLayer("yogini"));
|
||||
assert.ok((state().view as ChartViewOk).dasha.yogini.periods.length > 0);
|
||||
await h.update(() => refresh.resolve(response(initial)));
|
||||
assert.ok((state().view as ChartViewOk).dasha.yogini.periods.length > 0, "a D1-only refresh does not erase yogini");
|
||||
}));
|
||||
|
||||
test("empty yogini periods fail only that layer and the same request can run again", async () => harness(async (h, state) => {
|
||||
const initial = await packet();
|
||||
const yogini = await packet("yogini");
|
||||
assert.equal(yogini.body.status, "ok");
|
||||
if (yogini.body.status !== "ok") return;
|
||||
const emptyBody = {
|
||||
...yogini.body,
|
||||
dasha: {
|
||||
...yogini.body.dasha,
|
||||
yogini: { ...yogini.body.dasha.yogini, periods: [] },
|
||||
},
|
||||
};
|
||||
let yoginiCalls = 0;
|
||||
writeChartPage({ kind: "view", view: initial.body });
|
||||
globalThis.fetch = async (url) => {
|
||||
if (!String(url).includes("layers=yogini")) return response(initial);
|
||||
yoginiCalls += 1;
|
||||
return response({ ...yogini, body: emptyBody });
|
||||
};
|
||||
await h.render(null);
|
||||
await h.update(() => state().requestLayer("yogini"));
|
||||
assert.equal(state().layerFailures.get("yogini"), "unavailable");
|
||||
assert.equal(state().layerFailures.has("chara"), false);
|
||||
assert.equal((state().view as ChartViewOk).dasha.vimshottari.periods.length, (initial.body as ChartViewOk).dasha.vimshottari.periods.length);
|
||||
await h.update(() => state().requestLayer("yogini"));
|
||||
assert.equal(yoginiCalls, 2);
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user