Merge codex/chart-dasha-yogini-20261021 (#88) into staging
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-10-22 — 星盘大运右栏可以切到 Yogini
|
||||
|
||||
- 大运页左栏仍是 Vimshottari。右栏默认 Chara,可以切到 Yogini。点开当前大运能看到小运。Yogini 的小运下面写「小运未经外部软件核对。」
|
||||
- Skill 版本不变。
|
||||
|
||||
## 2026-10-21 — 导出弹窗:没有英文时顶部提示一次,选中态用主题色
|
||||
|
||||
- 英文版不可用时,打开导出弹窗在顶部提示一次,弹窗里不再留那行说明。英文可用时,「建议选 English」仍在语言选项下面。
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# 进度:星盘大运右栏加上 Yogini(2026-10-22)
|
||||
|
||||
分支 `codex/chart-dasha-yogini-20261021`。起点 `28dd8df333370dd7f6c2ed56656f9e83f978382f`(已含 #86、#87)。没有 push。
|
||||
|
||||
## 提交
|
||||
|
||||
| 步 | SHA | 说明 |
|
||||
| --- | --- | --- |
|
||||
| T1 | `6eeb98c7745f134d0609e0cacd1bb27bbc9c7502` | 小运按比例单列,大运函数输出不变 |
|
||||
| T2 | `3490e89403f3fcfe99a1d91260e8b4320cb0193e` | 只在接口返回的副本上挂小运 |
|
||||
| T3 | `85567071c74f6460ec917aebc1011f429b346269` | 三张公开名人盘对照 PyJHora,小运做内部核对 |
|
||||
| T4 | `7a296c7348abb73295b186ac0ed80ee376f15a01` | 按需取 Yogini,缓存按体系分开 |
|
||||
| T5 | `5ebbd57b041b65c8a6e28ba8be168d57c57b8497` | 按轨道合并,刷新保留已到的轨 |
|
||||
| T6 | `7b1070f86d1911f7b693724ce580c944dd751e61` | 右栏 Chara / Yogini 切换 |
|
||||
| T7 | 本文件所在提交 | 切换条样式、DESIGN、VOICE、CHANGELOG、真人清单 |
|
||||
|
||||
## 对照
|
||||
|
||||
PyJHora `4.8.7`。Presley、Spears、Einstein 三张公开盘走 `compute_chart` 再 `_compute_dasha_system`。大运主星顺序一致,起止日误差都在 1 天以内。没有改算法,没有放宽误差,没有换样本。
|
||||
|
||||
小运不跟 PyJHora 比。时长 = 大运 `full_years` × 子运年数 ÷ 36,从该大运自己的 Yogini 起轮转 8 位。页面写「小运未经外部软件核对。」
|
||||
|
||||
完整解读仍直接调用 `calculate_yogini_dasha`。接口行去掉 `antardasha` 之后与完整解读的大运行逐字段相同;完整解读的行没有 `antardasha`。Ashtottari 与 `scripts/jyotish_api_server.py` 没有改。
|
||||
|
||||
## 测试
|
||||
|
||||
- `tests/test_yogini_seed_profile.py` + `tests/test_dasha_api_engine_parity.py`:15 passed,0 failed,0 skipped(`-rs` 没有 skip)。其中种子 5,接口对照 10。
|
||||
- `frontend/tests/chart-dasha-yogini.test.ts`:4 passed,0 failed。
|
||||
- `frontend/tests/chart-page-hook.test.ts`:28 passed,0 failed。
|
||||
- `frontend/tests/chart-page-view.test.tsx`:28 passed,0 failed。
|
||||
- `frontend/tests/chart-view-route.test.ts`:12 passed,0 failed。
|
||||
- `frontend/tests/chart-view-zero-year.test.ts`:4 passed,0 failed。
|
||||
- `frontend/tests/chart-planet-glyphs.test.tsx`:13 passed,0 failed。
|
||||
- `frontend/tests/chart-dasha-switch-layout.test.ts`:1 passed,0 failed。
|
||||
- `tsc --noEmit`:0 错。
|
||||
- `npm run lint`:0 error,122 条既有 warning。
|
||||
|
||||
没有跑 Python 快速门、全量 `npm test`、`next build`。这三件留给验收。没有新增 BUG。真人清单只写步骤。
|
||||
@@ -516,7 +516,7 @@
|
||||
| `TASK-report-export-dialog-polish-20261021.md` | `PROGRESS-report-export-dialog-polish-20261021.md` | #90 导出弹窗:英文不可用说明改顶部浮层(每次打开一次);选中态描边 / 圆点 / 勾选框用主题色、底色中性(推翻 DESIGN「Selected states use canvas-muted + ink」,BUG-1078 主语保留) | 已合入 staging | `codex/report-export-dialog-polish-20261021` |
|
||||
| `TASK-chart-ascendant-slash-20261021.md` | `PROGRESS-chart-ascendant-slash-20261021.md` | #87 上升状态格「不适用」改「/」+ 悬停 / 点按说明;报告上升行同改并在表下加说明(推翻 10-15 口径) | 已合入 staging | `codex/chart-ascendant-slash-20261021` |
|
||||
| `TASK-report-english-han-leak-20261021.md` | `PROGRESS-report-english-han-leak-20261021.md` | #89 个人报告英文版约 4 成整份丢弃:漏译汉字源头补英文孪生字段、修吃空格;200 张固定种子回归 + 快速门 smoke(复发自 BUG-1272);中文逐字节不变 | 待领取(最先做;#91/#92 在后) | `codex/report-english-han-leak-20261021` |
|
||||
| `TASK-chart-dasha-yogini-20261021.md` | — | #88 星盘页大运右栏切换 Chara / Yogini,Yogini 补小运(按比例,内部核对,页面注明未经外部软件核对);Ashtottari 另立单(起运表与 PyJHora 8/12 宿不一致) | 待领取(等 #86、#87 合入) | `codex/chart-dasha-yogini-20261021` |
|
||||
| `TASK-chart-dasha-yogini-20261021.md` | — | #88 星盘页大运右栏切换 Chara / Yogini,Yogini 补小运(按比例,内部核对,页面注明未经外部软件核对);Ashtottari 另立单(起运表与 PyJHora 8/12 宿不一致) | 已合入 staging | `codex/chart-dasha-yogini-20261021` |
|
||||
| `TASK-full-data-parity-1-20261021.md` | — | #91 完整数据版补齐(一):A 段渲染(中文附录改中文源、71 小节归 6 章、导出星座名)→ B 段引擎(Yogi 点、标准大运子运、Kalachakra 简化子运、年运三年全展开、36 个月星历、解释补长);不设大小上限 | 待领取(等 #89 合入;T3 与 #90 串行) | `codex/full-data-parity-1-20261021` |
|
||||
| `TASK-goel-jaimini-strength-20261022.md` | `PROGRESS-goel-jaimini-strength-20261022.md` | #94 搬上游公开的 Goel Jaimini 强度与 Brahma / Maheshwara / Rudra(4 个上游原样文件,按 #81 决策 A 先例根目录预算 448→452;来源串改书目引用);只做计算、不改报告;冻结身份不变 | 已合入 staging | `codex/goel-jaimini-strength-20261022` |
|
||||
| `TASK-chart-subject-table-fix-20261022.md` | `PROGRESS-chart-subject-table-fix-20261022.md` | **#86 第 1 次打回**:报告所有表被强制单行(打印跑出纸面);保存他人复制了档案页代码且已分叉;点编辑框内边距会丢修改 | 已合入 staging | `codex/chart-subject-table-20261021` |
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# 星盘大运切换(真人)
|
||||
|
||||
桌面和手机各做一遍。不要在这份清单上写「已通过」。
|
||||
|
||||
1. 打开星盘页,点「大运」。左栏是 Vimshottari。右栏默认是 Chara,能看到「现在」卡。
|
||||
2. 右栏切到 Yogini。等这一栏出来后,点开当前大运,能看到小运。列表下面有一句「小运未经外部软件核对。」
|
||||
3. 再切回 Chara。Chara 仍在,左栏 Vimshottari 没有被换掉。Chara 下面没有那句小运核对说明。
|
||||
4. 看「现在」卡:大运、小运、小运剩余都在。
|
||||
5. 把窗口收到手机宽度(约 375px)。两栏改成上下排。右栏的 Chara / Yogini 切换不把页面撑出横向滚动,两个选项都能点到。
|
||||
+7
-3
@@ -1110,7 +1110,7 @@ Three product rules for assistant markdown in `.message-markdown` (BUG-962 / 963
|
||||
|
||||
独立 route `/chart`,不进 `page.tsx`。外壳先画、主盘先到、其余 tab 按需。不扣点、不调模型。盘面从现有路径复用 `VedicChartSvg`,不搬家。
|
||||
|
||||
`/chart` 是静态壳:首字节不取决于引擎。进页只请求 `/api/chart`。分盘(`/api/varga_full`,D2–D60 一次取齐)、大运 Chara(`/api/dasha/chara`)、西洋(`/api/western`)、Bhava(`/api/bhava_chalit`)、行运(按当天日期的 `/api/chart`)在用户点到对应 tab / 盘型时才请求。月亮盘从已到的 D1 投影,不发请求。未点的不发请求。
|
||||
`/chart` 是静态壳:首字节不取决于引擎。进页只请求 `/api/chart`。分盘(`/api/varga_full`,D2–D60 一次取齐)、大运 Chara(`/api/dasha/chara`,点开大运 Tab 才请求)、Yogini(`/api/dasha`,缓存范围 `dasha:yogini`,只在右栏切到 Yogini 时请求)、西洋(`/api/western`)、Bhava(`/api/bhava_chalit`)、行运(按当天日期的 `/api/chart`)在用户点到对应 tab / 盘型 / 大运体系时才请求。月亮盘从已到的 D1 投影,不发请求。未点的不发请求。
|
||||
|
||||
**七政四余 2026-09-28 起不在星盘页展示**(TASK-chart-types D1):Tab、组件、懒加载层与契约字段已删,后端 `/api/qizheng`、引擎与 vendor 保留,以后作对话里的补充。
|
||||
|
||||
@@ -1213,9 +1213,13 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句
|
||||
|
||||
0 年大运照实显示,不改引擎。某段 Chara 的 `duration_years` 为 0 时,这一段标注「0 年」,起止落在同一天,不占时间轴,也不展开子运;子运区写「本段时长为 0,不展开」。前一段的结束日就是下一段的开始日。
|
||||
|
||||
### 大运 Tab(2026-09-25)
|
||||
### 大运 Tab(2026-09-25;右栏切换 2026-10-22)
|
||||
|
||||
每条轨道自上而下是「现在」卡、时间条、可折叠大运列表。≥1024 时 Vimshottari 在左、Chara 在右;更窄则上下排。底部仍是那句双轨说明。
|
||||
每条轨道自上而下是「现在」卡、时间条、可折叠大运列表。左栏固定 Vimshottari。右栏在 Chara 与 Yogini 之间切换,默认 Chara。切换条在右栏卡片里,两个选项各至少 44px,横排分掉这一栏的宽度,不另开横向滚动。≥1024 时两栏并排;更窄则上下排,切换条跟着右栏走,不另加断点。底部仍是那句双轨说明。
|
||||
|
||||
点「大运」只取 Chara(`/api/dasha/chara`)。切到 Yogini 才请求 `/api/dasha`,缓存范围是 `dasha:yogini`,和 Chara 不是同一条缓存。回来时只换当前这一轨,已经拿到的另一轨还在。刷新本命盘也保留已经到达的大运轨。
|
||||
|
||||
Yogini 还在取时,右栏用和 Chara 相同的轨道等待(`OrbitWaiting`),读屏是「这一栏还没拿到。」失败只写在右栏,左栏 Vimshottari 不动。再点当前这个失败的选项会再请求一次。Yogini 列表下面固定一句「小运未经外部软件核对。」Chara 与 Vimshottari 不写这句。
|
||||
|
||||
「现在」卡三行:大运、小运、小运剩余 N 个月。月份按排盘所用的 `asOf` 到当前小运结束日的整月数,结束日当天不往下减。没有落入的时段写「未落入已返回的时段」,没有小运写「—」。
|
||||
|
||||
|
||||
@@ -220,6 +220,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
- 星历这一天没取到:「这一天的星历没取到,点日期再试一次。」
|
||||
- 0 年大运不展开:「本段时长为 0,不展开。」时长本身写「0 年」。这一行是灰色,不能点开。
|
||||
- 大运「现在」卡只报三件事:大运名称、小运名称、`小运剩余 N 个月`。没有落入已返回的时段时,大运写「未落入已返回的时段」,小运和小运剩余写「—」。不解释吉凶。
|
||||
- Yogini 的小运列表下面固定写「小运未经外部软件核对。」Chara 和 Vimshottari 不写这句。
|
||||
- 西洋盘要点只报位置,例如:`上升 处女 21°40′`、`太阳 双子 23°48′`、`月亮 双鱼 10°55′`、`元素最多 土`、`星群 摩羯`。同一星座少于 3 颗就不写星群。点中的行星没有主要相位时,相位表写「没有主要相位」。
|
||||
|
||||
没有出生资料时,标题是「还没有星盘资料」,按钮是「去填写出生资料」。资料读不出来、已采用时间缺时区、时区没有对上,正文仍用各自的原句,只共用这颗按钮。
|
||||
|
||||
@@ -6076,6 +6076,19 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
|
||||
font-size: var(--type-title-md);
|
||||
font-weight: 500;
|
||||
}
|
||||
.chart-page-dasha-switch {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
margin: 0 0 var(--space-3);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.chart-page-dasha-switch .chart-page-tab {
|
||||
flex: 1 1 0;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
text-align: center;
|
||||
}
|
||||
.chart-page-planet-card p { margin: 0 0 var(--space-2); font-size: var(--type-body-sm); }
|
||||
.chart-page-symbol { color: var(--color-ink-tertiary); }
|
||||
.chart-page-dasha-now {
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
|
||||
import { chartLayerFailureCopy } from "@/lib/chart-view-failure";
|
||||
import { OrbitWaiting } from "@/components/orbit-waiting";
|
||||
import type { ChartViewFailureStatus, ChartViewOk } from "@/lib/chart-view-contract";
|
||||
import type { ChartViewLayer } from "@/lib/chart-view-engine";
|
||||
import { CHARA_ZERO_YEAR_LABEL, CHARA_ZERO_YEAR_NOTE, CHART_VIEW_COPY } from "@/lib/chart-view-labels";
|
||||
|
||||
type DashaTrack = ChartViewOk["dasha"]["vimshottari"];
|
||||
type DashaPeriod = DashaTrack["periods"][number];
|
||||
type DashaSystemId = "chara" | "yogini";
|
||||
|
||||
const DASHA_SYSTEMS: ReadonlyArray<{ id: DashaSystemId; label: string }> = [
|
||||
{ id: "chara", label: "Chara" },
|
||||
{ id: "yogini", label: "Yogini" },
|
||||
];
|
||||
|
||||
function formatYearMonth(iso: string): string {
|
||||
return `${iso.slice(0, 4)}.${iso.slice(5, 7)}`;
|
||||
@@ -90,9 +101,11 @@ function Track({
|
||||
antardashaNote,
|
||||
periods,
|
||||
asOf,
|
||||
}: DashaTrack & { asOf: string }) {
|
||||
leading,
|
||||
}: DashaTrack & { asOf: string; leading?: ReactNode }) {
|
||||
return (
|
||||
<section className="chart-page-dasha-track">
|
||||
{leading}
|
||||
<header>
|
||||
<h3>{title}</h3>
|
||||
{method && method !== title ? <p>{method}</p> : null}
|
||||
@@ -141,26 +154,61 @@ function Track({
|
||||
|
||||
export function ChartDashaTab({
|
||||
view,
|
||||
charaPending = false,
|
||||
charaFailure,
|
||||
pendingLayers,
|
||||
layerFailures,
|
||||
system: controlledSystem,
|
||||
initialSystem = "chara",
|
||||
onSelectSystem,
|
||||
}: {
|
||||
view: ChartViewOk;
|
||||
charaPending?: boolean;
|
||||
charaFailure?: ChartViewFailureStatus;
|
||||
pendingLayers?: ReadonlySet<ChartViewLayer>;
|
||||
layerFailures?: ReadonlyMap<ChartViewLayer, ChartViewFailureStatus>;
|
||||
system?: DashaSystemId;
|
||||
initialSystem?: DashaSystemId;
|
||||
onSelectSystem?: (system: DashaSystemId) => void;
|
||||
}) {
|
||||
const [uncontrolledSystem, setUncontrolledSystem] = useState<DashaSystemId>(initialSystem);
|
||||
const system = controlledSystem ?? uncontrolledSystem;
|
||||
const asOf = view.dasha.asOf;
|
||||
const pending = pendingLayers?.has(system) ?? false;
|
||||
const failure = layerFailures?.get(system);
|
||||
const track = view.dasha[system];
|
||||
|
||||
function selectSystem(id: DashaSystemId) {
|
||||
if (controlledSystem === undefined) setUncontrolledSystem(id);
|
||||
onSelectSystem?.(id);
|
||||
}
|
||||
|
||||
const switcher = (
|
||||
<div className="chart-page-dasha-switch" role="tablist" aria-label="大运体系">
|
||||
{DASHA_SYSTEMS.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={system === item.id}
|
||||
className={system === item.id ? "chart-page-tab is-current" : "chart-page-tab"}
|
||||
onClick={() => selectSystem(item.id)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="chart-page-dasha">
|
||||
<div className="chart-page-dasha-columns">
|
||||
<Track {...view.dasha.vimshottari} asOf={asOf} />
|
||||
{charaPending || charaFailure ? (
|
||||
{pending || failure ? (
|
||||
<section className="chart-page-dasha-track">
|
||||
{charaPending
|
||||
{switcher}
|
||||
{pending
|
||||
? <OrbitWaiting label={CHART_VIEW_COPY.waitingLayer} />
|
||||
: <p className="chart-page-note">{chartLayerFailureCopy(charaFailure!)}</p>}
|
||||
: <p className="chart-page-note">{chartLayerFailureCopy(failure!)}</p>}
|
||||
</section>
|
||||
) : (
|
||||
<Track {...view.dasha.chara} asOf={asOf} />
|
||||
<Track {...track} asOf={asOf} leading={switcher} />
|
||||
)}
|
||||
</div>
|
||||
<p className="chart-page-boundary">{view.dasha.dualTrackNote}</p>
|
||||
|
||||
@@ -95,6 +95,7 @@ export function ChartPageView({
|
||||
const [tab, setTab] = useState<ChartViewTabId>(initialTab);
|
||||
const [vargaId, setVargaId] = useState<ChartTypeId>("D1");
|
||||
const [editingBirth, setEditingBirth] = useState(false);
|
||||
const [dashaSystem, setDashaSystem] = useState<"chara" | "yogini">("chara");
|
||||
|
||||
function selectTab(id: ChartViewTabId) {
|
||||
setTab(id);
|
||||
@@ -102,6 +103,11 @@ export function ChartPageView({
|
||||
if (id === "western") onNeedLayer?.("western");
|
||||
}
|
||||
|
||||
function selectDashaSystem(id: "chara" | "yogini") {
|
||||
setDashaSystem(id);
|
||||
onNeedLayer?.(id);
|
||||
}
|
||||
|
||||
function selectVarga(id: ChartTypeId) {
|
||||
setVargaId(id);
|
||||
const layer = chartTypeLayer(id);
|
||||
@@ -126,7 +132,10 @@ export function ChartPageView({
|
||||
|
||||
async function afterBirthSaved() {
|
||||
await onBirthSaved?.();
|
||||
if (tab === "dasha") onNeedLayer?.("chara");
|
||||
if (tab === "dasha") {
|
||||
onNeedLayer?.("chara");
|
||||
if (dashaSystem === "yogini") onNeedLayer?.("yogini");
|
||||
}
|
||||
if (tab === "western") onNeedLayer?.("western");
|
||||
if (tab === "vedic") {
|
||||
const layer = chartTypeLayer(vargaId);
|
||||
@@ -185,7 +194,13 @@ export function ChartPageView({
|
||||
) : null}
|
||||
{tab === "basics" ? <ChartBasicsTab view={view} /> : null}
|
||||
{tab === "dasha" ? (
|
||||
<ChartDashaTab view={view} charaPending={pendingLayers?.has("chara") ?? false} charaFailure={layerFailures?.get("chara")} />
|
||||
<ChartDashaTab
|
||||
view={view}
|
||||
pendingLayers={pendingLayers}
|
||||
layerFailures={layerFailures}
|
||||
system={dashaSystem}
|
||||
onSelectSystem={selectDashaSystem}
|
||||
/>
|
||||
) : null}
|
||||
{tab === "western" ? (
|
||||
pendingLayers?.has("western") ? (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -195,7 +195,7 @@ export const chartViewMahadashaSchema = chartViewPeriodSchema.extend({
|
||||
});
|
||||
|
||||
export const chartViewDashaTrackSchema = z.object({
|
||||
id: z.enum(["vimshottari", "chara"]),
|
||||
id: z.enum(["vimshottari", "chara", "yogini"]),
|
||||
title: z.string().min(1),
|
||||
method: z.string().min(1),
|
||||
currentLabel: z.string().min(1),
|
||||
@@ -280,6 +280,7 @@ export const chartViewOkSchema = z.object({
|
||||
layerFailures: z.object({
|
||||
varga: chartViewFailureStatusSchema.optional(),
|
||||
chara: chartViewFailureStatusSchema.optional(),
|
||||
yogini: chartViewFailureStatusSchema.optional(),
|
||||
western: chartViewFailureStatusSchema.optional(),
|
||||
bhava: chartViewFailureStatusSchema.optional(),
|
||||
transit: chartViewFailureStatusSchema.optional(),
|
||||
@@ -301,6 +302,7 @@ export const chartViewOkSchema = z.object({
|
||||
asOf: isoDate,
|
||||
vimshottari: chartViewDashaTrackSchema,
|
||||
chara: chartViewDashaTrackSchema,
|
||||
yogini: chartViewDashaTrackSchema,
|
||||
dualTrackNote: z.string().min(1),
|
||||
}),
|
||||
western: z.union([chartViewWesternOkSchema, chartViewUnavailableLayerSchema]),
|
||||
|
||||
@@ -2,9 +2,30 @@ export const CHART_VIEW_ENGINE_TIMEOUT_MS = 10_000;
|
||||
export const CHART_VIEW_ENGINE_CACHE_TTL_MS = 5 * 60 * 1_000;
|
||||
const ENGINE_CACHE_MAX_ENTRIES = 200;
|
||||
|
||||
export const CHART_VIEW_LAYERS = ["varga", "chara", "western", "bhava", "transit"] as const;
|
||||
export const CHART_VIEW_LAYERS = ["varga", "chara", "yogini", "western", "bhava", "transit"] as const;
|
||||
export type ChartViewLayer = (typeof CHART_VIEW_LAYERS)[number];
|
||||
|
||||
/**
|
||||
* On-demand dasha columns. A new `/api/dasha` system is one row here plus a
|
||||
* mapper. Chara keeps its own path, so its cache scope stays empty and the
|
||||
* historical key does not move. Every `/api/dasha` row must set `cacheScope`,
|
||||
* because those calls share one path.
|
||||
*/
|
||||
export const CHART_DASHA_SYSTEMS = [
|
||||
{ layer: "chara", dasha: "chara", cacheScope: null, track: "chara", path: "/api/dasha/chara" },
|
||||
{ layer: "yogini", dasha: "yogini", cacheScope: "dasha:yogini", track: "yogini", path: "/api/dasha" },
|
||||
] as const;
|
||||
|
||||
export type ChartDashaSystem = (typeof CHART_DASHA_SYSTEMS)[number];
|
||||
|
||||
export function dashaCacheScope(dashaKey: string): string {
|
||||
return `dasha:${dashaKey}`;
|
||||
}
|
||||
|
||||
export function dashaSystemByLayer(layer: string): ChartDashaSystem | null {
|
||||
return CHART_DASHA_SYSTEMS.find((item) => item.layer === layer) ?? null;
|
||||
}
|
||||
|
||||
export type EngineCallResult =
|
||||
| { readonly status: "ok"; readonly payload: Record<string, unknown> }
|
||||
| {
|
||||
|
||||
@@ -173,6 +173,8 @@ export const ANTARDASHA_DERIVED_NOTE = "分运按标准比例从大运边界推
|
||||
|
||||
export const CHARA_ANTARDASHA_DATE_NOTE = "分运时长来自引擎的十二等分;起止日期按大运边界推算,不是引擎单独返回的日历字段。";
|
||||
|
||||
export const YOGINI_ANTARDASHA_UNVERIFIED_NOTE = "小运未经外部软件核对。";
|
||||
|
||||
export const CHARA_ZERO_YEAR_LABEL = "0 年";
|
||||
|
||||
export const CHARA_ZERO_YEAR_NOTE = "本段时长为 0,不展开";
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
type ChartViewResponse,
|
||||
} from "./chart-view-contract.ts";
|
||||
import {
|
||||
CHART_DASHA_SYSTEMS,
|
||||
dashaCacheScope,
|
||||
enginePayload,
|
||||
logEngineCallFailure,
|
||||
type ChartViewLayer,
|
||||
@@ -94,6 +96,26 @@ function wantedLayers(layers: readonly ChartViewLayer[] | undefined): Set<ChartV
|
||||
return new Set(layers ?? []);
|
||||
}
|
||||
|
||||
/** One row of `CHART_DASHA_SYSTEMS`, or any other `/api/dasha` key passed by a test. */
|
||||
export function chartDashaLayerRequest(dashaKey: string, followUp: Record<string, unknown>): {
|
||||
path: string;
|
||||
body: Record<string, unknown>;
|
||||
cacheScope?: string;
|
||||
} {
|
||||
const system = CHART_DASHA_SYSTEMS.find((item) => item.dasha === dashaKey);
|
||||
if (system && system.path !== "/api/dasha") {
|
||||
return {
|
||||
path: system.path,
|
||||
body: { ...followUp, antardasha: true, mode: "dasha" },
|
||||
};
|
||||
}
|
||||
return {
|
||||
path: "/api/dasha",
|
||||
body: { ...followUp, dasha: dashaKey },
|
||||
cacheScope: system?.cacheScope ?? dashaCacheScope(dashaKey),
|
||||
};
|
||||
}
|
||||
|
||||
async function callEngine(
|
||||
postEngine: ChartViewEnginePost,
|
||||
path: string,
|
||||
@@ -167,20 +189,23 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{
|
||||
return enginePayload(result);
|
||||
};
|
||||
const transitDate = input.asOf.slice(0, 10);
|
||||
const [varga, chara, western, bhava, transit] = await Promise.all([
|
||||
const dashaPackets: Record<(typeof CHART_DASHA_SYSTEMS)[number]["track"], Record<string, unknown> | null> = {
|
||||
chara: null,
|
||||
yogini: null,
|
||||
};
|
||||
const dashaReady = Promise.all(CHART_DASHA_SYSTEMS.map(async (system) => {
|
||||
if (!layers.has(system.layer)) return;
|
||||
const request = chartDashaLayerRequest(system.dasha, followUp);
|
||||
const result = await callEngine(input.postEngine, request.path, request.body, request.cacheScope);
|
||||
dashaPackets[system.track] = layerPayload(system.layer)(result);
|
||||
}));
|
||||
const [varga, western, bhava, transit] = await Promise.all([
|
||||
layers.has("varga")
|
||||
? callEngine(input.postEngine, "/api/varga_full", {
|
||||
...followUp,
|
||||
divisions: VARGA_CHIP_ORDER.map((division) => `D${division}`),
|
||||
}).then(layerPayload("varga"))
|
||||
: Promise.resolve(null),
|
||||
layers.has("chara")
|
||||
? callEngine(input.postEngine, "/api/dasha/chara", {
|
||||
...followUp,
|
||||
antardasha: true,
|
||||
mode: "dasha",
|
||||
}).then(layerPayload("chara"))
|
||||
: Promise.resolve(null),
|
||||
layers.has("western")
|
||||
? callEngine(input.postEngine, "/api/western", payload).then(layerPayload("western"))
|
||||
: Promise.resolve(null),
|
||||
@@ -209,12 +234,14 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{
|
||||
).then(layerPayload("transit"))
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
await dashaReady;
|
||||
|
||||
try {
|
||||
const body = buildChartView({
|
||||
chart,
|
||||
varga,
|
||||
chara,
|
||||
chara: dashaPackets.chara,
|
||||
yogini: dashaPackets.yogini,
|
||||
western,
|
||||
bhava,
|
||||
transit,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
VIMSHOTTARI_ORDER,
|
||||
VIMSHOTTARI_YEARS,
|
||||
WESTERN_UNAVAILABLE_COPY,
|
||||
YOGINI_ANTARDASHA_UNVERIFIED_NOTE,
|
||||
ASPECT_ZH,
|
||||
ELEMENT_ZH,
|
||||
MODE_ZH,
|
||||
@@ -58,6 +59,7 @@ export type ChartViewEngineBundle = {
|
||||
chart: Record<string, unknown>;
|
||||
varga: Record<string, unknown> | null;
|
||||
chara: Record<string, unknown> | null;
|
||||
yogini?: Record<string, unknown> | null;
|
||||
western: Record<string, unknown> | null;
|
||||
bhava?: Record<string, unknown> | null;
|
||||
transit?: Record<string, unknown> | null;
|
||||
@@ -700,6 +702,68 @@ function charaTrack(charaPacket: Record<string, unknown> | null, birthDate: stri
|
||||
};
|
||||
}
|
||||
|
||||
function yoginiPieceLabel(yogini: string, planet: string): string {
|
||||
return `${yogini} / ${planetZh(planet)}`;
|
||||
}
|
||||
|
||||
function yoginiTrack(packet: Record<string, unknown> | null, asOf: string) {
|
||||
const rows = Array.isArray(packet?.periods) ? packet.periods : [];
|
||||
const periods = rows.flatMap((entry) => {
|
||||
const row = record(entry);
|
||||
if (!row) return [];
|
||||
const planet = text(row.planet) ?? text(row.lord);
|
||||
const yogini = text(row.yogini);
|
||||
const start = text(row.start_date);
|
||||
const end = text(row.end_date);
|
||||
if (!planet || !yogini || !start || !end) return [];
|
||||
const startIso = isoDay(start);
|
||||
const endIso = isoDay(end);
|
||||
const rawAntar = Array.isArray(row.antardasha) ? row.antardasha : [];
|
||||
const antardashas = rawAntar.flatMap((antar) => {
|
||||
const item = record(antar);
|
||||
if (!item) return [];
|
||||
const antarPlanet = text(item.planet) ?? text(item.lord);
|
||||
const antarYogini = text(item.yogini);
|
||||
const antarStart = text(item.start_date);
|
||||
const antarEnd = text(item.end_date);
|
||||
if (!antarPlanet || !antarYogini || !antarStart || !antarEnd) return [];
|
||||
const subStart = isoDay(antarStart);
|
||||
const subEnd = isoDay(antarEnd);
|
||||
return [{
|
||||
lord: antarPlanet,
|
||||
label: yoginiPieceLabel(antarYogini, antarPlanet),
|
||||
start: subStart,
|
||||
end: subEnd,
|
||||
current: containsDay(subStart, subEnd, asOf),
|
||||
derived: false,
|
||||
}];
|
||||
});
|
||||
return [{
|
||||
lord: planet,
|
||||
label: yoginiPieceLabel(yogini, planet),
|
||||
sign: null,
|
||||
start: startIso,
|
||||
end: endIso,
|
||||
current: containsDay(startIso, endIso, asOf),
|
||||
derived: false,
|
||||
antardashas,
|
||||
}];
|
||||
});
|
||||
const current = periods.find((item) => item.current);
|
||||
const currentAntar = current?.antardashas.find((item) => item.current);
|
||||
const currentLabel = current
|
||||
? `${current.label}${currentAntar ? ` / ${currentAntar.label}` : ""}`
|
||||
: "未落入已返回的时段";
|
||||
return {
|
||||
id: "yogini" as const,
|
||||
title: "Yogini",
|
||||
method: "Yogini",
|
||||
currentLabel,
|
||||
antardashaNote: YOGINI_ANTARDASHA_UNVERIFIED_NOTE,
|
||||
periods,
|
||||
};
|
||||
}
|
||||
|
||||
function unavailableWestern(reason: string): ChartViewUnavailableLayer {
|
||||
return { status: "unavailable", reason, copy: WESTERN_UNAVAILABLE_COPY };
|
||||
}
|
||||
@@ -789,7 +853,7 @@ function mapWestern(raw: Record<string, unknown> | null, ayanamsaDisplay: string
|
||||
}
|
||||
|
||||
export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk {
|
||||
const { chart, varga, chara, western, bhava, transit, profile, asOf } = bundle;
|
||||
const { chart, varga, chara, yogini = null, western, bhava, transit, profile, asOf } = bundle;
|
||||
const planets = asPlanetMap(chart.planets);
|
||||
const moon = planets.Moon ?? {};
|
||||
const ascendant = record(chart.ascendant);
|
||||
@@ -801,6 +865,7 @@ export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk {
|
||||
if (vargas.length === 0) throw new Error("vedic_chart_unavailable");
|
||||
const vimshottari = vimshottariTrack(chart, asOf);
|
||||
const charaMapped = charaTrack(chara, profile.date, asOf);
|
||||
const yoginiMapped = yoginiTrack(yogini, asOf);
|
||||
const placeLabel = profile.placeLabel?.trim()
|
||||
|| formatCoord(profile.latitude, profile.longitude);
|
||||
const view = {
|
||||
@@ -839,6 +904,7 @@ export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk {
|
||||
asOf: isoDay(asOf),
|
||||
vimshottari,
|
||||
chara: charaMapped,
|
||||
yogini: yoginiMapped,
|
||||
dualTrackNote: DASHA_DUAL_TRACK_NOTE,
|
||||
},
|
||||
western: mapWestern(western, ayanamsaDisplay),
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { cssDeclarations } from "./css-contract-test-support.ts";
|
||||
|
||||
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
|
||||
test("the dasha switch stays inside the column and keeps a 44px hit area", () => {
|
||||
const row = cssDeclarations(".chart-page-dasha-switch", globalStyles);
|
||||
assert.match(row, /display:\s*flex/);
|
||||
assert.match(row, /overflow-x:\s*hidden/);
|
||||
assert.doesNotMatch(row, /overflow-x:\s*auto/);
|
||||
const option = cssDeclarations(".chart-page-dasha-switch .chart-page-tab", globalStyles);
|
||||
assert.match(option, /min-height:\s*44px/);
|
||||
assert.match(option, /min-width:\s*44px/);
|
||||
assert.match(option, /flex:\s*1 1 0/);
|
||||
assert.match(globalStyles, /@media \(min-width: 1024px\)/);
|
||||
assert.match(globalStyles, /\.chart-page-dasha-columns/);
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import test from "node:test";
|
||||
|
||||
import { ChartDashaTab } from "../src/components/chart-page/chart-dasha-tab.tsx";
|
||||
import { chartViewOkSchema, type ChartViewOk } from "../src/lib/chart-view-contract.ts";
|
||||
import {
|
||||
chartViewEngineCacheKey,
|
||||
chartViewEngineCachePath,
|
||||
dashaCacheScope,
|
||||
} from "../src/lib/chart-view-engine.ts";
|
||||
import { assembleChartView, chartDashaLayerRequest } from "../src/lib/chart-view-load.ts";
|
||||
import { ANTARDASHA_DERIVED_NOTE, CHARA_ANTARDASHA_DATE_NOTE, YOGINI_ANTARDASHA_UNVERIFIED_NOTE } from "../src/lib/chart-view-labels.ts";
|
||||
|
||||
Object.assign(globalThis, { React });
|
||||
|
||||
const fixtureText = readFileSync(new URL("./fixtures/yogini-dasha-api-1990-01-01.golden.json", import.meta.url), "utf8");
|
||||
const yoginiFixture = JSON.parse(fixtureText) as {
|
||||
sourceCommand: string;
|
||||
ayanamsa: string;
|
||||
node_mode: string;
|
||||
response: Record<string, unknown>;
|
||||
};
|
||||
const golden = JSON.parse(
|
||||
readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8"),
|
||||
) as { chart: Record<string, unknown>; chara: Record<string, unknown> };
|
||||
|
||||
const profile = {
|
||||
name: "示例",
|
||||
date: "1990-06-15",
|
||||
time: "12:00",
|
||||
placeLabel: "北京",
|
||||
latitude: 39.9042,
|
||||
longitude: 116.4074,
|
||||
timezoneOffset: 8,
|
||||
timezoneId: "Asia/Shanghai",
|
||||
ayanamsa: "raman",
|
||||
birthTimeStatus: "confirmed" as const,
|
||||
};
|
||||
|
||||
const person = {
|
||||
userId: "user-1",
|
||||
subjectId: "self",
|
||||
date: "1990-01-01",
|
||||
time: "12:00",
|
||||
latitude: 28.6139,
|
||||
longitude: 77.209,
|
||||
timezoneOffset: 5.5,
|
||||
ayanamsa: "lahiri",
|
||||
nodeMode: "mean",
|
||||
};
|
||||
|
||||
test("the yogini fixture is a real dasha response, not a hand-built shape", () => {
|
||||
assert.match(yoginiFixture.sourceCommand, /_compute_dasha_system/);
|
||||
assert.match(yoginiFixture.sourceCommand, /dasha=yogini/);
|
||||
assert.equal(yoginiFixture.ayanamsa, "lahiri");
|
||||
assert.equal(yoginiFixture.node_mode, "mean");
|
||||
const periods = yoginiFixture.response.periods;
|
||||
assert.ok(Array.isArray(periods) && periods.length > 0);
|
||||
});
|
||||
|
||||
test("the same person gets a different cache key for each dasha system", () => {
|
||||
const followUp = { date: person.date, time: person.time };
|
||||
const chara = chartDashaLayerRequest("chara", followUp);
|
||||
const yogini = chartDashaLayerRequest("yogini", followUp);
|
||||
const kalachakra = chartDashaLayerRequest("kalachakra", followUp);
|
||||
assert.equal(chara.path, "/api/dasha/chara");
|
||||
assert.equal(chara.cacheScope, undefined);
|
||||
assert.equal(yogini.path, "/api/dasha");
|
||||
assert.equal(yogini.cacheScope, "dasha:yogini");
|
||||
assert.equal(yogini.body.dasha, "yogini");
|
||||
assert.equal(kalachakra.path, "/api/dasha");
|
||||
assert.equal(kalachakra.cacheScope, dashaCacheScope("kalachakra"));
|
||||
assert.equal(kalachakra.body.dasha, "kalachakra");
|
||||
const key = (path: string, cacheScope?: string) => chartViewEngineCacheKey({
|
||||
...person,
|
||||
path: chartViewEngineCachePath(path, cacheScope),
|
||||
});
|
||||
const keys = [
|
||||
key(chara.path, chara.cacheScope),
|
||||
key(yogini.path, yogini.cacheScope),
|
||||
key(kalachakra.path, kalachakra.cacheScope),
|
||||
];
|
||||
assert.equal(new Set(keys).size, 3);
|
||||
assert.notEqual(JSON.stringify(chara.body), JSON.stringify(yogini.body));
|
||||
assert.notEqual(JSON.stringify(yogini.body), JSON.stringify(kalachakra.body));
|
||||
});
|
||||
|
||||
async function yoginiView(): Promise<ChartViewOk> {
|
||||
const scopes: Array<string | undefined> = [];
|
||||
const result = await assembleChartView({
|
||||
userId: "user-1",
|
||||
profile,
|
||||
asOf: "1990-06-01",
|
||||
layers: ["chara", "yogini"],
|
||||
postEngine: async (path, body, cacheScope) => {
|
||||
if (path === "/api/chart") return { status: "ok", payload: golden.chart };
|
||||
if (path === "/api/dasha/chara") return { status: "ok", payload: golden.chara };
|
||||
if (path === "/api/dasha") {
|
||||
scopes.push(cacheScope);
|
||||
assert.equal(body.dasha, "yogini");
|
||||
return { status: "ok", payload: yoginiFixture.response };
|
||||
}
|
||||
return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 };
|
||||
},
|
||||
});
|
||||
assert.deepEqual(scopes, ["dasha:yogini"]);
|
||||
return chartViewOkSchema.parse(result.body);
|
||||
}
|
||||
|
||||
test("a real yogini response maps dates and labels without deriving them", async () => {
|
||||
const view = await yoginiView();
|
||||
const track = view.dasha.yogini;
|
||||
assert.equal(track.id, "yogini");
|
||||
const first = track.periods[0];
|
||||
assert.ok(first);
|
||||
assert.equal(first.label, "Pingala / 太阳");
|
||||
assert.equal(first.derived, false);
|
||||
assert.equal(first.start, "1990-01-01");
|
||||
assert.equal(first.end, "1990-01-12");
|
||||
assert.equal(first.antardashas.length, 1);
|
||||
assert.equal(first.antardashas[0]?.label, "Mangala / 月亮");
|
||||
assert.equal(first.antardashas[0]?.derived, false);
|
||||
assert.equal(first.antardashas[0]?.start, "1990-01-01");
|
||||
const dhanya = track.periods.find((period) => period.label === "Dhanya / 木星");
|
||||
assert.ok(dhanya);
|
||||
assert.equal(dhanya.derived, false);
|
||||
assert.equal(dhanya.antardashas.length, 8);
|
||||
assert.equal(dhanya.antardashas.every((item) => item.derived === false), true);
|
||||
assert.equal(dhanya.current, true);
|
||||
assert.equal(dhanya.antardashas.some((item) => item.current), true);
|
||||
assert.equal(track.antardashaNote, YOGINI_ANTARDASHA_UNVERIFIED_NOTE);
|
||||
});
|
||||
|
||||
test("the yogini note sits under that track only", async () => {
|
||||
const view = await yoginiView();
|
||||
const yogini = renderToStaticMarkup(React.createElement(ChartDashaTab, { view, system: "yogini" }));
|
||||
assert.equal((yogini.match(/小运未经外部软件核对。/g) ?? []).length, 1);
|
||||
assert.match(yogini, /<dt>大运<\/dt>/);
|
||||
assert.match(yogini, /<dt>小运<\/dt>/);
|
||||
assert.match(yogini, /<dt>小运剩余<\/dt><dd>\d+ 个月<\/dd>/);
|
||||
assert.match(yogini, /Dhanya \/ 木星/);
|
||||
assert.match(yogini, new RegExp(ANTARDASHA_DERIVED_NOTE));
|
||||
assert.doesNotMatch(yogini, new RegExp(CHARA_ANTARDASHA_DATE_NOTE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
const chara = renderToStaticMarkup(React.createElement(ChartDashaTab, { view }));
|
||||
assert.equal((chara.match(/小运未经外部软件核对。/g) ?? []).length, 0);
|
||||
assert.match(chara, new RegExp(CHARA_ANTARDASHA_DATE_NOTE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
assert.match(chara, new RegExp(ANTARDASHA_DERIVED_NOTE));
|
||||
assert.match(chara, /aria-selected="true"[^>]*>Chara</);
|
||||
});
|
||||
@@ -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);
|
||||
}));
|
||||
|
||||
@@ -11,14 +11,15 @@ import { EMPTY_NORTH_INDIAN_CHART, VedicChartSvg } from "../src/components/perso
|
||||
import { ChartPageView } from "../src/components/chart-page/chart-page-view.tsx";
|
||||
import { SidebarProvider } from "../src/components/ui/sidebar.tsx";
|
||||
import { assembleChartView } from "../src/lib/chart-view-load.ts";
|
||||
import { CHART_VIEW_TABS, type ChartViewOk } from "../src/lib/chart-view-contract.ts";
|
||||
import { CHART_VIEW_LAYERS } from "../src/lib/chart-view-engine.ts";
|
||||
import { CHART_VIEW_TABS, type ChartViewFailureStatus, type ChartViewOk } from "../src/lib/chart-view-contract.ts";
|
||||
import { CHART_VIEW_LAYERS, type ChartViewLayer } from "../src/lib/chart-view-engine.ts";
|
||||
import { chartViewFailureResponse } from "../src/lib/chart-view-failure.ts";
|
||||
import { CHART_VIEW_COPY } from "../src/lib/chart-view-labels.ts";
|
||||
import { CHART_SETTINGS_HREF } from "../src/lib/settings-url.ts";
|
||||
import { westernLabelBox } from "../src/components/chart-page/western-wheel-layout.ts";
|
||||
import { cssDeclarations } from "./css-contract-test-support.ts";
|
||||
import { goldenSky } from "./birth-sky-test-support.ts";
|
||||
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
|
||||
|
||||
Object.assign(globalThis, { React });
|
||||
|
||||
@@ -551,3 +552,70 @@ test("birth-sky prefetch follows the current subject and the dialog is a lazy ch
|
||||
assert.match(cover, /await import\("\.\/render\.ts"\)/);
|
||||
assert.doesNotMatch(chartView, /birth-sky-dialog|lib\/birth-sky\/(draw|render)/);
|
||||
});
|
||||
|
||||
function buttonByLabel(h: ReturnType<typeof createClientLifecycleHarness>, label: string) {
|
||||
const node = h.elements().find((item) => item.tagName === "BUTTON" && item.text === label);
|
||||
assert.ok(node, `missing button ${label}`);
|
||||
return node;
|
||||
}
|
||||
|
||||
test("opening dasha requests only chara, and Yogini is a separate request", async () => {
|
||||
const view = await okView();
|
||||
const calls: string[] = [];
|
||||
const h = createClientLifecycleHarness();
|
||||
try {
|
||||
await h.render(React.createElement(SidebarProvider, null, React.createElement(ChartPageView, {
|
||||
view,
|
||||
onNeedLayer: (layer: string) => { calls.push(layer); },
|
||||
})));
|
||||
await h.event(buttonByLabel(h, "大运"));
|
||||
assert.deepEqual(calls, ["chara"]);
|
||||
assert.match(h.container.text, /Chara Dasha(kn_rao 变体)/);
|
||||
assert.match(h.container.text, /Vimshottari/);
|
||||
await h.event(buttonByLabel(h, "Yogini"));
|
||||
assert.deepEqual(calls, ["chara", "yogini"]);
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a yogini failure stays in the right column, and clicking Yogini again asks again", async () => {
|
||||
const view = await okView();
|
||||
const calls: string[] = [];
|
||||
const h = createClientLifecycleHarness();
|
||||
try {
|
||||
await h.render(React.createElement(SidebarProvider, null, React.createElement(ChartPageView, {
|
||||
view,
|
||||
layerFailures: new Map<ChartViewLayer, ChartViewFailureStatus>([["yogini", "engine_busy"]]),
|
||||
onNeedLayer: (layer: string) => { calls.push(layer); },
|
||||
})));
|
||||
await h.event(buttonByLabel(h, "大运"));
|
||||
await h.event(buttonByLabel(h, "Yogini"));
|
||||
assert.match(h.container.text, /Vimshottari/);
|
||||
assert.match(h.container.text, new RegExp(CHART_VIEW_COPY.busy));
|
||||
assert.doesNotMatch(h.container.text, /小运未经外部软件核对。/);
|
||||
assert.equal(h.elements().some((node) => node.hasAttribute("data-skeleton")), false);
|
||||
await h.event(buttonByLabel(h, "Yogini"));
|
||||
assert.deepEqual(calls, ["chara", "yogini", "yogini"]);
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("yogini pending reuses the orbit sentence and does not draw a skeleton", async () => {
|
||||
const view = await okView();
|
||||
const h = createClientLifecycleHarness();
|
||||
try {
|
||||
await h.render(React.createElement(SidebarProvider, null, React.createElement(ChartPageView, {
|
||||
view,
|
||||
pendingLayers: new Set<ChartViewLayer>(["yogini"]),
|
||||
})));
|
||||
await h.event(buttonByLabel(h, "大运"));
|
||||
await h.event(buttonByLabel(h, "Yogini"));
|
||||
assert.match(h.container.text, /这一栏还没拿到/);
|
||||
assert.match(h.container.text, /Vimshottari/);
|
||||
assert.equal(h.elements().some((node) => node.hasAttribute("data-skeleton")), false);
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -94,7 +94,13 @@ test("the chart-view BFF never bills and never calls a model", () => {
|
||||
assert.match(serviceSource, /loadSubjectBirth/);
|
||||
assert.doesNotMatch(serviceSource, /clientBirth|body\.profile/);
|
||||
assert.match(loadSource, /\/api\/chart/);
|
||||
assert.match(loadSource, /\/api\/dasha\/chara/);
|
||||
// 原值: loadSource 匹配 /api/dasha/chara
|
||||
// 新值: 路径写在 CHART_DASHA_SYSTEMS,loadSource 经 chartDashaLayerRequest 取用
|
||||
// 原因: Chara 与 Yogini 共用一张大运层表,Yogini 走 /api/dasha 且缓存 scope 是 dasha:yogini(issue #88)
|
||||
const engineSource = readFileSync(new URL("../src/lib/chart-view-engine.ts", import.meta.url), "utf8");
|
||||
assert.match(engineSource, /\/api\/dasha\/chara/);
|
||||
assert.match(engineSource, /dasha:yogini/);
|
||||
assert.match(loadSource, /chartDashaLayerRequest/);
|
||||
assert.match(loadSource, /\/api\/varga_full/);
|
||||
assert.match(loadSource, /planets/);
|
||||
assert.match(loadSource, /ascendant/);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ This module must not import `jyotish_api_server` or PyJHora.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -287,8 +288,22 @@ def compute_dasha(
|
||||
body = body or {}
|
||||
alt, conditional, planet_lons = build_reading_birth_info(birth_dt, planets or {}, ascendant, body)
|
||||
if key == "yogini":
|
||||
payload = _load("yogini_dasha").calculate_yogini_dasha({**alt, "moon_longitude": conditional["moon_longitude"]})
|
||||
return {"periods": _major_rows(payload)}
|
||||
module = _load("yogini_dasha")
|
||||
payload = module.calculate_yogini_dasha({**alt, "moon_longitude": conditional["moon_longitude"]})
|
||||
year_days = 365.25
|
||||
if isinstance(payload, dict) and payload.get("dasha_year_days") is not None:
|
||||
year_days = float(payload["dasha_year_days"])
|
||||
periods = []
|
||||
for row in _major_rows(payload):
|
||||
copied = copy.deepcopy(row)
|
||||
if isinstance(copied, dict):
|
||||
copied["antardasha"] = module.yogini_antardashas(
|
||||
copied,
|
||||
birth_dt=birth_dt,
|
||||
year_days=year_days,
|
||||
)
|
||||
periods.append(copied)
|
||||
return {"periods": periods}
|
||||
if key == "ashtottari":
|
||||
payload = _load("ashtottari_dasha").calculate_ashtottari_dasha({
|
||||
**alt,
|
||||
|
||||
@@ -61,10 +61,63 @@ def calculate_yogini_dasha(birth_info: dict) -> dict:
|
||||
moon_longitude: sidereal degrees, required
|
||||
as_of / today: observation instant; report callers should pass it
|
||||
dasha_year_days: fixed-day year, default 365.25
|
||||
|
||||
Major rows stay free of sub-periods. Callers that need antardashas use
|
||||
``yogini_antardashas`` on a copy.
|
||||
"""
|
||||
return _calculate_moon_balanced_yogini(birth_info)
|
||||
|
||||
|
||||
def _as_datetime(value):
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
return datetime.fromisoformat(str(value))
|
||||
|
||||
|
||||
def yogini_antardashas(major_row: dict, *, birth_dt, year_days: float) -> list:
|
||||
"""Proportional antardashas inside one Yogini mahadasha.
|
||||
|
||||
Eight sub-periods start at this mahadasha's own Yogini and follow Goel
|
||||
Table I. Each length is ``full_years * sub_years / 36``. They are laid out
|
||||
from ``unclipped_start_date``. Sub-periods that end at or before birth are
|
||||
dropped. The one that crosses birth keeps ``unclipped_start_date`` and
|
||||
starts at birth. The last sub-period ends on the mahadasha ``end_date``.
|
||||
"""
|
||||
birth = _as_datetime(birth_dt)
|
||||
year_length = float(year_days)
|
||||
if year_length <= 0:
|
||||
raise ValueError('dasha_year_days must be positive')
|
||||
start_idx = next(
|
||||
index for index, item in enumerate(YOGINI_SEQUENCE) if item['name'] == major_row['yogini']
|
||||
)
|
||||
full_years = float(major_row['full_years'])
|
||||
cursor = _as_datetime(major_row['unclipped_start_date'])
|
||||
major_end = _as_datetime(major_row['end_date'])
|
||||
rows = []
|
||||
for offset in range(len(YOGINI_SEQUENCE)):
|
||||
item = YOGINI_SEQUENCE[(start_idx + offset) % len(YOGINI_SEQUENCE)]
|
||||
span_days = full_years * item['years'] / TOTAL_CYCLE * year_length
|
||||
end = major_end if offset == len(YOGINI_SEQUENCE) - 1 else cursor + timedelta(days=span_days)
|
||||
if end <= birth:
|
||||
cursor = end
|
||||
continue
|
||||
visible_start = birth if cursor < birth else cursor
|
||||
row = {
|
||||
'level': 'antardasha',
|
||||
'planet': item['planet'],
|
||||
'yogini': item['name'],
|
||||
'lord': item['planet'],
|
||||
'years': (end - visible_start).total_seconds() / (86400 * year_length),
|
||||
'start_date': visible_start.isoformat(timespec='seconds'),
|
||||
'end_date': end.isoformat(timespec='seconds'),
|
||||
}
|
||||
if cursor < birth:
|
||||
row['unclipped_start_date'] = cursor.isoformat(timespec='seconds')
|
||||
rows.append(row)
|
||||
cursor = end
|
||||
return rows
|
||||
|
||||
|
||||
def _calculate_moon_balanced_yogini(birth_info):
|
||||
"""Goel Table I order, angular birth balance, explicit fixed-day year."""
|
||||
birth_dt = birth_info.get("birth_datetime")
|
||||
|
||||
@@ -16,7 +16,7 @@ import os
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
from datetime import date, datetime
|
||||
from datetime import date, datetime, timedelta
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
@@ -201,6 +201,18 @@ def _period_covering(majors, moment: datetime):
|
||||
return None
|
||||
|
||||
|
||||
def _without_antardasha(rows):
|
||||
cleaned = []
|
||||
for row in rows:
|
||||
if isinstance(row, dict) and "antardasha" in row:
|
||||
item = dict(row)
|
||||
item.pop("antardasha")
|
||||
cleaned.append(item)
|
||||
else:
|
||||
cleaned.append(row)
|
||||
return cleaned
|
||||
|
||||
|
||||
def test_api_major_periods_match_full_reading_modules() -> None:
|
||||
report = _fiction_reading()
|
||||
modules = report["modules"]
|
||||
@@ -210,7 +222,16 @@ def test_api_major_periods_match_full_reading_modules() -> None:
|
||||
result = handler._compute_dasha_system(_chart_body(report, key))
|
||||
assert result["precision"] == "engine_module", key
|
||||
assert result["precision"] not in {"generic", "calculator"}
|
||||
assert result["periods"] == engine_rows, key
|
||||
if key == "yogini":
|
||||
# 原值:result["periods"] == engine_rows
|
||||
# 新值:去掉 antardasha 后相等
|
||||
# 原因:小运只挂在接口返回的副本上,完整解读的 Yogini 行保持原字段
|
||||
assert _without_antardasha(result["periods"]) == engine_rows, key
|
||||
assert result["periods"]
|
||||
assert all(isinstance(row, dict) and row.get("antardasha") for row in result["periods"])
|
||||
assert all(isinstance(row, dict) and "antardasha" not in row for row in engine_rows)
|
||||
else:
|
||||
assert result["periods"] == engine_rows, key
|
||||
|
||||
|
||||
def test_unimplemented_dasha_keys_are_rejected() -> None:
|
||||
@@ -425,6 +446,172 @@ def test_ashtottari_birth_balance_matches_pyjhora_within_one_day() -> None:
|
||||
_public_case("spears", spears, "moon")
|
||||
|
||||
|
||||
YOGINI_PUBLIC_CHARTS = (
|
||||
("presley", {
|
||||
"year": 1935,
|
||||
"month": 1,
|
||||
"day": 8,
|
||||
"hour": 4,
|
||||
"minute": 35,
|
||||
"lat": 34.3,
|
||||
"lon": -88.4,
|
||||
"tz": -6.0,
|
||||
}),
|
||||
("spears", {
|
||||
"year": 1981,
|
||||
"month": 12,
|
||||
"day": 2,
|
||||
"hour": 19,
|
||||
"minute": 55,
|
||||
"lat": 31.17,
|
||||
"lon": -89.18,
|
||||
"tz": 0.0,
|
||||
}),
|
||||
("einstein", {
|
||||
"year": 1879,
|
||||
"month": 3,
|
||||
"day": 14,
|
||||
"hour": 11,
|
||||
"minute": 30,
|
||||
"lat": 48.4,
|
||||
"lon": 9.98,
|
||||
"tz": 0.89,
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
def _api_yogini_periods(birth: dict) -> list:
|
||||
from scripts.domain_calculation_service import compute_chart
|
||||
|
||||
chart = compute_chart({**birth, "second": 0, "node_mode": "mean", "ayanamsa": "lahiri"})
|
||||
planets = {
|
||||
name: {"lon": row["lon"]}
|
||||
for name, row in chart["planets"].items()
|
||||
if isinstance(row, dict) and isinstance(row.get("lon"), (int, float))
|
||||
}
|
||||
body = {
|
||||
"dasha": "yogini",
|
||||
"year": birth["year"],
|
||||
"month": birth["month"],
|
||||
"day": birth["day"],
|
||||
"hour": birth["hour"],
|
||||
"minute": birth["minute"],
|
||||
"second": 0,
|
||||
"lat": birth["lat"],
|
||||
"lon": birth["lon"],
|
||||
"tz": birth["tz"],
|
||||
"today": "2026-10-22",
|
||||
"ayanamsa": "lahiri",
|
||||
"node_mode": "mean",
|
||||
"planets": planets,
|
||||
"ascendant": {"lon": chart["ascendant"]["lon"]},
|
||||
}
|
||||
result = _handler()._compute_dasha_system(body)
|
||||
assert result["precision"] == "engine_module"
|
||||
return result["periods"]
|
||||
|
||||
|
||||
def _pyjhora_yogini_end(row, following) -> date:
|
||||
if following is not None:
|
||||
return date(*following[1][:3])
|
||||
from jhora import const
|
||||
|
||||
parts = row[1]
|
||||
hours = float(parts[3]) if len(parts) > 3 else 0.0
|
||||
start = datetime(int(parts[0]), int(parts[1]), int(parts[2])) + timedelta(hours=hours)
|
||||
return (start + timedelta(days=float(row[2]) * float(const.sidereal_year))).date()
|
||||
|
||||
|
||||
def _assert_yogini_majors_against_pyjhora(name: str, periods: list, py_rows: list) -> None:
|
||||
assert len(py_rows) >= 24, name
|
||||
assert len(periods) >= 24, name
|
||||
mismatches = []
|
||||
for index, (engine, oracle) in enumerate(zip(periods[:24], py_rows[:24])):
|
||||
engine_lord = engine.get("planet")
|
||||
oracle_lord = PYJHORA_LORDS[oracle[0][0]]
|
||||
engine_start = _as_date(engine["unclipped_start_date"])
|
||||
oracle_start = date(*oracle[1][:3])
|
||||
following = py_rows[index + 1] if index + 1 < 24 else None
|
||||
engine_end = _as_date(engine["end_date"])
|
||||
oracle_end = _pyjhora_yogini_end(oracle, following)
|
||||
start_delta = abs((engine_start - oracle_start).days)
|
||||
end_delta = abs((engine_end - oracle_end).days)
|
||||
if engine_lord != oracle_lord or start_delta > 1 or end_delta > 1:
|
||||
mismatches.append(
|
||||
f"{name} major[{index}] engine={engine_lord} {engine_start}..{engine_end} "
|
||||
f"pyjhora={oracle_lord} {oracle_start}..{oracle_end} "
|
||||
f"start_delta_days={start_delta} end_delta_days={end_delta}"
|
||||
)
|
||||
assert not mismatches, "\n".join(mismatches)
|
||||
|
||||
|
||||
def _assert_yogini_antardasha_consistency(name: str, periods: list, birth: datetime) -> None:
|
||||
from scripts.yogini_dasha import TOTAL_CYCLE, YOGINI_SEQUENCE
|
||||
|
||||
year_days = 365.25
|
||||
problems = []
|
||||
for index, major in enumerate(periods):
|
||||
subs = major.get("antardasha") or []
|
||||
if not subs:
|
||||
problems.append(f"{name} major[{index}] {major.get('yogini')} has no antardasha")
|
||||
continue
|
||||
start_idx = next(item_index for item_index, item in enumerate(YOGINI_SEQUENCE) if item["name"] == major["yogini"])
|
||||
expected = [YOGINI_SEQUENCE[(start_idx + offset) % 8] for offset in range(8)]
|
||||
got = [row["yogini"] for row in subs]
|
||||
suffixes = [[item["name"] for item in expected[cut:]] for cut in range(8)]
|
||||
if got not in suffixes:
|
||||
problems.append(f"{name} major[{index}] order {got} is not a Goel suffix of {major['yogini']}")
|
||||
continue
|
||||
matched = expected[8 - len(got):]
|
||||
for row, item in zip(subs, matched):
|
||||
if row.get("level") != "antardasha" or row.get("planet") != item["planet"] or row.get("lord") != item["planet"]:
|
||||
problems.append(f"{name} major[{index}] {row.get('yogini')} fields {row.get('level')} {row.get('planet')} {row.get('lord')}")
|
||||
raw_start = datetime.fromisoformat(row.get("unclipped_start_date") or row["start_date"])
|
||||
raw_end = datetime.fromisoformat(row["end_date"])
|
||||
expected_seconds = float(major["full_years"]) * item["years"] / TOTAL_CYCLE * year_days * 86400
|
||||
delta_seconds = abs((raw_end - raw_start).total_seconds() - expected_seconds)
|
||||
if delta_seconds > 2:
|
||||
problems.append(
|
||||
f"{name} major[{index}] {item['name']} span_delta_seconds={delta_seconds:.3f}"
|
||||
)
|
||||
for left, right in zip(subs, subs[1:]):
|
||||
if left["end_date"] != right["start_date"]:
|
||||
problems.append(f"{name} major[{index}] gap {left['end_date']} -> {right['start_date']}")
|
||||
if subs[0]["start_date"] != major["start_date"]:
|
||||
problems.append(f"{name} major[{index}] first sub {subs[0]['start_date']} != major {major['start_date']}")
|
||||
if index == 0 and datetime.fromisoformat(subs[0]["start_date"]) != birth:
|
||||
problems.append(f"{name} first sub {subs[0]['start_date']} != birth {birth.isoformat(timespec='seconds')}")
|
||||
if index > 0 and any("unclipped_start_date" in row for row in subs):
|
||||
problems.append(f"{name} major[{index}] later mahadasha was clipped")
|
||||
visible = sum(
|
||||
(datetime.fromisoformat(row["end_date"]) - datetime.fromisoformat(row["start_date"])).total_seconds()
|
||||
for row in subs
|
||||
)
|
||||
major_span = (datetime.fromisoformat(major["end_date"]) - datetime.fromisoformat(major["start_date"])).total_seconds()
|
||||
if abs(visible - major_span) > 86400:
|
||||
problems.append(f"{name} major[{index}] sub_sum_delta_seconds={visible - major_span:.3f}")
|
||||
if index == 0 and major["unclipped_start_date"] == major["start_date"]:
|
||||
if len(subs) != 8:
|
||||
problems.append(f"{name} unclipped first major has {len(subs)} subs")
|
||||
for row, item in zip(subs, expected):
|
||||
formula = float(major["full_years"]) * item["years"] / TOTAL_CYCLE
|
||||
if abs(float(row["years"]) - formula) > 1e-6:
|
||||
problems.append(f"{name} unclipped {item['name']} years {row['years']} != {formula}")
|
||||
assert not problems, "\n".join(problems)
|
||||
|
||||
|
||||
def test_yogini_api_majors_match_pyjhora_and_antardashas_are_proportional() -> None:
|
||||
import importlib.metadata as metadata
|
||||
|
||||
version = metadata.version("PyJHora")
|
||||
assert version == "4.8.7", version
|
||||
for name, birth in YOGINI_PUBLIC_CHARTS:
|
||||
periods = _api_yogini_periods(birth)
|
||||
born = datetime(birth["year"], birth["month"], birth["day"], birth["hour"], birth["minute"])
|
||||
_assert_yogini_majors_against_pyjhora(name, periods, _pyjhora_rows("yogini", birth))
|
||||
_assert_yogini_antardasha_consistency(name, periods, born)
|
||||
|
||||
|
||||
def test_placeholder_modules_are_gone() -> None:
|
||||
assert not (SCRIPTS / "extended_dashas.py").exists()
|
||||
assert not (SCRIPTS / "conditional_dashas.py").exists()
|
||||
|
||||
@@ -2,7 +2,7 @@ from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.yogini_dasha import YOGINI_SEQUENCE, calculate_yogini_dasha
|
||||
from scripts.yogini_dasha import YOGINI_SEQUENCE, TOTAL_CYCLE, calculate_yogini_dasha, yogini_antardashas
|
||||
|
||||
|
||||
def test_yogini_classical_sequence_and_balance():
|
||||
@@ -57,3 +57,77 @@ def test_yogini_explicit_reference_controls_current_cycle():
|
||||
# Ashwini start: (0 + 3) % 8 is Bhramari / Mars, and the balance is the whole period.
|
||||
assert first['starting_planet'] == 'Mars'
|
||||
assert first['birth_balance_years'] == pytest.approx(4.0)
|
||||
|
||||
|
||||
def _sub_names(start_name):
|
||||
start = next(index for index, item in enumerate(YOGINI_SEQUENCE) if item['name'] == start_name)
|
||||
return [YOGINI_SEQUENCE[(start + offset) % 8]['name'] for offset in range(8)]
|
||||
|
||||
|
||||
def test_yogini_antardasha_proportional_on_unclipped_seed():
|
||||
# Moon at Ashwini 0°: Bhramari opens on the birth instant, so nothing is clipped.
|
||||
birth = datetime(2000, 1, 1)
|
||||
year_days = 360
|
||||
result = calculate_yogini_dasha({
|
||||
'birth_datetime': birth,
|
||||
'moon_longitude': 0.0,
|
||||
'as_of': '2000-01-02',
|
||||
'dasha_year_days': year_days,
|
||||
})
|
||||
assert all('antardasha' not in row for row in result['major'])
|
||||
major = result['major'][0]
|
||||
assert major['unclipped_start_date'] == major['start_date']
|
||||
subs = yogini_antardashas(major, birth_dt=birth, year_days=year_days)
|
||||
assert [row['yogini'] for row in subs] == _sub_names('Bhramari')
|
||||
assert all('unclipped_start_date' not in row for row in subs)
|
||||
assert subs[0]['start_date'] == major['start_date']
|
||||
assert subs[-1]['end_date'] == major['end_date']
|
||||
assert all(left['end_date'] == right['start_date'] for left, right in zip(subs, subs[1:]))
|
||||
for row, item in zip(subs, YOGINI_SEQUENCE[3:] + YOGINI_SEQUENCE[:3]):
|
||||
assert row['level'] == 'antardasha'
|
||||
assert row['planet'] == row['lord'] == item['planet']
|
||||
assert row['years'] == pytest.approx(major['full_years'] * item['years'] / TOTAL_CYCLE)
|
||||
visible = sum(
|
||||
(datetime.fromisoformat(row['end_date']) - datetime.fromisoformat(row['start_date'])).total_seconds()
|
||||
for row in subs
|
||||
)
|
||||
major_span = (
|
||||
datetime.fromisoformat(major['end_date']) - datetime.fromisoformat(major['start_date'])
|
||||
).total_seconds()
|
||||
assert abs(visible - major_span) <= 86400
|
||||
|
||||
|
||||
def test_yogini_antardasha_clips_the_birth_crossing_subperiod():
|
||||
# Mid-Pushya is the same seed as test_yogini_classical_sequence_and_balance,
|
||||
# shifted so the elapsed balance falls inside Siddha rather than on a boundary.
|
||||
birth = datetime(2000, 1, 1, 12)
|
||||
year_days = 360
|
||||
moon = (7 + 0.6) * (360 / 27)
|
||||
result = calculate_yogini_dasha({
|
||||
'birth_datetime': birth,
|
||||
'moon_longitude': moon,
|
||||
'as_of': '2000-01-02',
|
||||
'dasha_year_days': year_days,
|
||||
})
|
||||
first = result['major'][0]
|
||||
later = result['major'][1]
|
||||
first_subs = yogini_antardashas(first, birth_dt=birth, year_days=year_days)
|
||||
later_subs = yogini_antardashas(later, birth_dt=birth, year_days=year_days)
|
||||
assert first_subs[0]['yogini'] == 'Siddha'
|
||||
assert first_subs[0]['start_date'] == first['start_date'] == birth.isoformat(timespec='seconds')
|
||||
assert datetime.fromisoformat(first_subs[0]['unclipped_start_date']) < birth
|
||||
assert [row['yogini'] for row in first_subs] == _sub_names('Dhanya')[-4:]
|
||||
assert later_subs[0]['start_date'] == later['start_date']
|
||||
assert [row['yogini'] for row in later_subs] == _sub_names(later['yogini'])
|
||||
assert 'unclipped_start_date' not in later_subs[0]
|
||||
for major, subs in ((first, first_subs), (later, later_subs)):
|
||||
assert all(left['end_date'] == right['start_date'] for left, right in zip(subs, subs[1:]))
|
||||
assert subs[-1]['end_date'] == major['end_date']
|
||||
visible = sum(
|
||||
(datetime.fromisoformat(row['end_date']) - datetime.fromisoformat(row['start_date'])).total_seconds()
|
||||
for row in subs
|
||||
)
|
||||
major_span = (
|
||||
datetime.fromisoformat(major['end_date']) - datetime.fromisoformat(major['start_date'])
|
||||
).total_seconds()
|
||||
assert abs(visible - major_span) == 0
|
||||
|
||||
Reference in New Issue
Block a user