From 5ebbd57b041b65c8a6e28ba8be168d57c57b8497 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Thu, 22 Oct 2026 03:55:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(dasha):=20=E5=A4=A7=E8=BF=90=E6=8C=89?= =?UTF-8?q?=E8=BD=A8=E9=81=93=E5=90=88=E5=B9=B6=EF=BC=8C=E5=88=B7=E6=96=B0?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E5=B7=B2=E5=88=B0=E7=9A=84=E8=BD=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chara 和 Yogini 各换各的一段。空的小运列表只让这一层失败,已经在页上的另一轨不动。 Co-Authored-By: Grok (agent loop) Co-authored-by: Cursor --- frontend/src/hooks/use-chart-page.ts | 11 ++- frontend/src/lib/secondary-page-data.ts | 14 ++- frontend/tests/chart-page-hook.test.ts | 124 +++++++++++++++++++++++- 3 files changed, 139 insertions(+), 10 deletions(-) diff --git a/frontend/src/hooks/use-chart-page.ts b/frontend/src/hooks/use-chart-page.ts index 9c1a7694..bc4a68a0 100644 --- a/frontend/src/hooks/use-chart-page.ts +++ b/frontend/src/hooks/use-chart-page.ts @@ -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" diff --git a/frontend/src/lib/secondary-page-data.ts b/frontend/src/lib/secondary-page-data.ts index 07bbe32d..41220d2f 100644 --- a/frontend/src/lib/secondary-page-data.ts +++ b/frontend/src/lib/secondary-page-data.ts @@ -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 { 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 } : {}), diff --git a/frontend/tests/chart-page-hook.test.ts b/frontend/tests/chart-page-hook.test.ts index 803a6ca5..58b8de34 100644 --- a/frontend/tests/chart-page-hook.test.ts +++ b/frontend/tests/chart-page-hook.test.ts @@ -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(); + 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); +}));