feat(dasha): 大运按轨道合并,刷新保留已到的轨

Chara 和 Yogini 各换各的一段。空的小运列表只让这一层失败,已经在页上的另一轨不动。

Co-Authored-By: Grok (agent loop)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jesse-ux
2026-10-22 03:55:42 +08:00
co-authored by Grok (agent loop) Cursor
parent 7a296c7348
commit 5ebbd57b04
3 changed files with 139 additions and 10 deletions
+7 -4
View File
@@ -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"
+12 -2
View File
@@ -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 } : {}),
+120 -4
View File
@@ -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);
}));