fix(report,chart): blank columns — drop Kranti, split Ashtakavarga house SAV, ascendant nakshatra, engine varga dignity, no duplicate charts (BUG-1199..1203)
Independent Staging Quality Gate / validate (push) Successful in 15m27s
Independent Staging Quality Gate / publish (push) Successful in 3m42s

- Report p33 table: no Kranti column (no producer; materials.kranti stays blocked).
- Report p53 table: the "SAV" column was the with-Lagna total (386). Split in the
  reader into SAV / Lagna BAV / SAV + Lagna with a totals row; ashtakavarga.py is
  untouched (frozen rectification identity, ERR-115).
- /api/chart ascendant carries nakshatra / pada / lord (_nakshatra_fields shared with
  the nine bodies); chart cache schema 4 -> 5.
- /api/varga_full bodies carry dignity_level from varga_dignity_level, the function
  the Vimsopaka path now shares (A/B identical); the chart page shows the D1 status
  wording and no longer derives a three-level dignity itself.
- Report page draws each chart once: the trailing block skips charts the body already
  fenced (same rule as the export); the birth-time caveat moves under the body's
  chart heading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
Jesse_Chen
2026-10-03 09:11:15 +08:00
co-authored by Claude Opus 5.5
parent 0e856f9cb6
commit ae18952e51
28 changed files with 1011 additions and 346 deletions
+9
View File
@@ -1,5 +1,14 @@
# 印度占星 Skill 更新日志
## 2026-10-03 — 报告和星盘页不再出现「一整列横杠」;八分法宫位表的 SAV 数字改正
- 个人报告「赤纬与速度」表删掉一直为空的 Kranti 列(本站没有它的独立算法,赤纬列已是真实值)(BUG-1199)。
- 个人报告「Ashtakavarga 完整宫位分数」表:以前标着 SAV 的那一列其实是「含上升合计」(12 宫合计 386),已改正为三列——SAV(合计 337)、上升贡献(合计 49)、含上升合计(386),末行加合计。生时校正使用的分数不变(BUG-1200)。
- 星盘页本命表上升那一行补上星宿、宿主、pada(例:狮子 14°32′ = Purva Phalguni 第 1 足、宿主金星)(BUG-1201)。
- 星盘页分盘表的「庙旺」列改为与本命「状态」列同一套十档说法(入旺、本垣、入庙、极友、入友、中性、入敌、极敌、落陷、落陷取消),以前只写入旺 / 自宫 / 落陷、其余一律「—」(BUG-1202)。
- 报告末尾不再重复画一遍正文已有的分盘;「分盘对出生时间敏感」这句挪到正文分盘标题下(BUG-1203)。旧报告正文里没有分盘图的,末尾照旧补上。
- Skill 版本不变;不改数据库结构。新生成的报告才有新表格,旧报告内容不变。
## 2026-10-02 — 生时校正至少要 4 件有年月的经历、覆盖 3 个方面才给时间范围;上游同步第五轮(燃烧、诅咒格措辞、Badhaka 等)
- 生时校正:以前 3 件有年月的经历、2 个方面(例如上学和工作)就会给出生时间范围;现在至少要 4 件、覆盖 3 个方面(例如再加一件搬家或感情)才开始给范围和选择题。还不够时,提示会写清楚还差几件、还缺哪类(例:「再来两件记得大概年月的事,其中至少一件不是上学或工作的,就能开始筛」),不再一律写「再来一件」(BUG-1193)。打分方法不变,算法版本不变;以前的校正记录照常打开。
+72
View File
@@ -16134,3 +16134,75 @@
- 防复发:同上。
- 相关记录:BUG-1195。
- 修复版本:`codex/upstream-sync5-20261002`(未推送)。
## BUG-1199 | 报告「Declination / Kranti / Speed」表的 Kranti 列整列为「-」
- 状态:resolved(分支验证;待部署 staging)
- 首次发现 / 最近更新:2026-10-03 / 2026-10-03
- 影响面:`scripts/pl9_reader_export.py` p33 表(中英两版报告正文与目录)。
- 现象:产品真机截图,个人报告「Declination / Kranti / Speed」表七行 Kranti 全是 `-`,像没算出来。
- 触发条件:任何一份报告。
- 根因:reader 读 `strengths.kranti_local.planets[*].kranti_degrees`,全仓没有任何 producer;引擎 materials `kranti` 自标 `missing_in_local / blocked`(no independent Kranti producer)。表照样留了这一列。
- 修复:产品选删列:表改为 `Planet | Degree | Declination | Speed`,标题 `Declination / Speed`。不按古典公式补算(无 PL9 原值可对照,Declination 已是真实赤纬)。materials `kranti: blocked` 审计口径与 `full_report_quality_gate.RESTRICTED_MATERIAL_IDS` 不变。
- 验证:`tests/test_report_chart_blank_columns.py::test_declination_table_has_no_kranti_column`(中英两版:无 Kranti、四列、无 `-`)。
- 防复发:没有 producer 的字段不进读者表格;要加列先有来源。
- 相关记录:BUG-1200~1203(同一张截图)。
- 复发自:无
- 修复版本:`codex/report-chart-blank-columns-20261003`
## BUG-1200 | 报告「Ashtakavarga 完整宫位分数」把含上升合计标成 SAV,另两列为「-」
- 状态:resolved(分支验证;待部署 staging)
- 首次发现 / 最近更新:2026-10-03 / 2026-10-03
- 影响面:`scripts/pl9_reader_export.py` p53 表(数据源 `scripts/ashtakavarga.py::_map_to_houses_values`,未改)。
- 现象:表头「House | Sign | SAV | BAV/承接 | Full with 上升」,后两列全 `-`;「SAV」列 12 宫合计 386,与同页「Sign | SAV score」表(合计 337)对不上。
- 触发条件:任何一份报告。
- 根因:`_map_to_houses_values(full_sav, …)` 只写 `sign` + `sav_score`,传入的是 `full_sav = sav + lagna_sav`(337 + 49);reader 用 `row.get('sav') or row.get('sav_score')` 取到含上升合计标成 SAV,`bav` / `full_with_lagna` 字段不存在。**数字标错,不只是缺列**。
- 修复:在 reader 拆分,**`ashtakavarga.py` 不改**——它在生时校正冻结身份(`sealed_holdout_rerun.PRODUCTION_FILES`)里,最初按任务书改它新增字段,快速门 `test_rectification_validation_integrity_gate` 4 条转红(ERR-110);改为 reader 用引擎已有的按星座 SAV(`sav.scores`)与上升 BAV(`bav.Lagna.bindus`)拆出,且要求 `SAV + 上升 BAV == house_scores_full.sav_score` 逐宫成立才填,否则 SAV / 上升两列写 `-`、不出合计行,绝不把合计填进 SAV 列。`sav_score` 语义与数值不变(生时校正在读)。表改为 `House | Sign | SAV | Lagna BAV | SAV + Lagna`(中文版「上升 BAV」「SAV + 上升」),末行合计。
- 验证:`tests/test_report_chart_blank_columns.py`:每行 SAV + 上升 BAV = 最后一列、合计 337 / 49 / 386(中英两版);SAV 列 = 按星座 SAV、上升列 = Lagna bindus、末列 = `house_scores_full.sav_score`;删掉 Lagna BAV 时 SAV / 上升两列为 `-` 且无合计行。验证完整性门禁与封存评测新鲜度测试保持全绿(冻结身份未变)。
- 防复发:报告表格列只读语义明确的字段,不用 `or` 链兜到别的口径。
- 相关记录:BUG-1199。
- 复发自:无
- 修复版本:`codex/report-chart-blank-columns-20261003`
## BUG-1201 | 星盘页本命表上升行的星宿 / 宿主 / pada 一直是「—」
- 状态:resolved(分支验证;待部署 staging)
- 首次发现 / 最近更新:2026-10-03 / 2026-10-03
- 影响面:`scripts/jyotish_engine.py::compute_chart_data`(`/api/chart` 的 `ascendant`)、`frontend` 星盘页 D1 / 月亮盘表上升行。
- 现象:上升行星宿、宿主、pada 三格为「—」,九曜都有。
- 根因:`compute_chart_data` 给九曜按 `nak_span` 算了 `nakshatra / nakshatra_pada / nakshatra_lord`,`result["ascendant"]` 漏了;前端按「引擎不给就不推」留空。
- 修复:抽 `_nakshatra_fields(sidereal_lon)`,九曜、计都、上升共用;`/api/chart` 缓存 `cache_schema_version` 4 → 5(缓存 TTL 900 s,改版本只影响查找键;指纹 / 绑定只用出生资料,不受影响)。前端只改注释。
- 验证:`tests/test_report_chart_blank_columns.py`(边界 0°、13°20′ 两侧、360°;golden 盘上升 Uttara Phalguni 1 足、九曜与 golden 逐星一致);`frontend/tests/chart-varga-table.test.tsx` 上升行断言;旧缓存无字段仍渲染「—」。golden `chart-view-golden.json` 用 `_nakshatra_fields` 重录(命令写在 `sourceCommand`)。
- 防复发:同一个量(星宿)只有一个计算函数,新增盘点直接复用。
- 相关记录:BUG-1076(D1 表逐字节基线:本次放开上升三格,见进度记录三栏)。
- 复发自:无
- 修复版本:`codex/report-chart-blank-columns-20261003`
## BUG-1202 | 星盘页分盘表「庙旺」只做三档,敌宫 / 中性等一律「—」
- 状态:resolved(分支验证;待部署 staging)
- 首次发现 / 最近更新:2026-10-03 / 2026-10-03
- 影响面:`/api/varga_full`(新模块 `scripts/varga_dignity.py`)、`jyotish_engine.varga_dignity_level`、`frontend/src/lib/chart-view-{varga,mapper,contract}.ts`、`vedic-chart-tables.json`。
- 现象:D9 表只有入旺 / 自宫 / 落陷有字(太阳在金牛=敌宫、月亮在摩羯=中性都是「—」),产品以为没算。
- 根因:BUG-1076 / 1099 有意只做三档,前端自带一份 `vargaDignity` 表;引擎 Vimsopaka 路径早有完整判定没接过来。
- 修复(产品 10-03 拍板,**有限推翻 BUG-1076「三档庙旺」与 BUG-1099「三档庙旺保留」**;BUG-1099 能力声明规则不变):抽 `varga_dignity_level(planet, varga_planets)`(暂时关系取该分盘自身位置,与 Vimsopaka 一致),Vimsopaka 与 `/api/varga_full` 共用;`/api/varga_full` 每星加 `dignity_level`;前端只校验并用 D1 状态列同一措辞(`DIGNITY_LABELS`)显示;删除前端 `vargaDignity` 及只为它服务的四张表。旧缓存无等级时整列隐藏,不自算。
- 验证:Vimsopaka 抽函数前后同机 A/B(`PYTHONHASHSEED=0`,3 张虚构盘 `cmd_full_reading` 的 vimsopaka / varga_full / jaimini / 行星 / 上升)逐字节相同;`tests/test_api_server_security.py::test_varga_full_standard_divisions_carry_the_vimsopaka_dignity_level`(与 `varga.calc_all_vargas` 的 Vimsopaka 输入逐星同座同级);`tests/test_chart_page_vedic_tables_contract.py`(真实 TS mapper 投影 = 引擎等级,golden 覆盖入庙 / 落陷 / 中性 / 入敌 / 入友;措辞表 = `DIGNITY_LABELS`);`frontend/tests/chart-varga-table.test.tsx`、`chart-view-varga-dignity.test.ts`。
- 防复发:分盘庙旺只有引擎一套;页面不得再写第二套判定。
- 相关记录:BUG-1076、BUG-1099。
- 复发自:无(范围扩展,不是回归)。
- 修复版本:`codex/report-chart-blank-columns-20261003`
## BUG-1203 | 报告末尾「分盘核对」把正文已画过的分盘再画一遍
- 状态:resolved(分支验证;待部署 staging)
- 首次发现 / 最近更新:2026-10-03 / 2026-10-03
- 影响面:`frontend/src/components/personal-report/report-calculation-charts.tsx`、`personal-report-page.tsx`、`src/lib/report-chart-block.ts`、`report-export-blocks.ts`、`scripts/pl9_reader_export.py`(提示句)。
- 现象:正文「本命与分盘北印度图盘」已画全部分盘,正文后又出现「分盘核对」+ 提示句 + D1、D2… 第二遍,在目录外、样式散。
- 根因:页面在 Markdown 正文后无条件挂 `ReportCalculationCharts`;导出早已按正文围栏去重,页面没用同一规则。
- 修复:抽 `fencedReportChartIds`,页面与导出共用;页面只画正文没有围栏的结构化分盘(新报告整段消失,老报告缺哪张补哪张)。提示句由引擎写到正文分盘标题下(英文版走术语表),组件里的提示删除。
- 验证:`frontend/tests/report-calculation-charts.test.tsx`(真实 golden 正文 → 整段不渲染;无围栏正文 → 照旧且不带提示;页面与导出跳过同一批盘);导出原有测试(含冻结哈希)全绿;`tests/test_report_chart_blank_columns.py::test_chart_section_carries_the_divisional_caveat`(中英)。
- 防复发:同一张盘页面只画一次;页面与导出共用去重函数。
- 相关记录:BUG-607 / 616(报告图盘渲染)。
- 复发自:无
- 修复版本:`codex/report-chart-blank-columns-20261003`
+6
View File
@@ -472,3 +472,9 @@ Prevention: `frontend/tests/runtime-sources-no-test-imports.test.ts`;研究 ha
`57782aea` 改 `scripts/functional_benefics.py`(功能吉凶 v2)让原生打分同输入出不同分(reported-offset 900 例里 230 例变化),但该文件不在 `sealed_holdout_rerun.py` `PRODUCTION_FILES` 和 dataset `frozen_scoring.files` 里,`tests/test_rectification_validation_integrity_gate.py` 全绿;随后只把 `scoring_service.py` 的 `ALGORITHM_VERSION` 从 scoring-9 改成 scoring-10,同一门禁 8 条失败。冻结身份是显式审过的文件清单,不是传递依赖,清单外的打分输入会静默漂移。
Prevention: 打分路径新增或改动输入模块时,先查它在不在两份清单里;`functional_benefics.py` 已补进 `PRODUCTION_FILES` 并由 `test_functional_roles_are_part_of_the_production_identity` 锁住;改动后按 ERR-110 重新冻结(本次标签 `functional_v2_2026_10_02`)。关联 BUG-1181、ERR-110。
## ERR-115 | 任务书指定给冻结文件 `ashtakavarga.py` 加展示字段,快速门验证完整性 4 条转红 | observed 2026-10-03
`TASK-report-chart-blank-columns-20261003` T2 写的是「`_map_to_houses_values` 增字段」,`scripts/ashtakavarga.py` 在 `sealed_holdout_rerun.PRODUCTION_FILES` 里,只加字段、分数不变也让 `test_rectification_validation_integrity_gate` 4 条与 `test_sealed_holdout_contract_freshness` 3 条失败(ERR-110 同型)。改为在 `pl9_reader_export.py` 用引擎已有字段拆分,冻结文件零改动,门禁恢复全绿,无需重新冻结。
Prevention: 写任务书时(不只是执行时)就对照 `PRODUCTION_FILES` 与 dataset `frozen_scoring.files`;只为展示的字段优先在消费层(reader / mapper)从已有数据推出,不进冻结文件。关联 ERR-110、BUG-1200。
@@ -0,0 +1,57 @@
# PROGRESS · 报告与星盘页的「空列」(2026-10-03)
- 任务书:`docs/tasks/TASK-report-chart-blank-columns-20261003.md`(`3bbc2daa`)
- 基线:`origin/staging` `3bbc2daa`(任务书写的 `1af34880` + 任务书本身,代码相同)
- 分支 / worktree:`codex/report-chart-blank-columns-20261003` / `.worktrees/report-chart-blank-columns-20261003`
- 执行方式:产品 10-03 说「请你执行」,Claude 直接实现并独立验收。
- BUG:1199~1203(开工核对最大号 1198)
## 完成情况
| 任务 | 结果 | 要点 |
| --- | --- | --- |
| T1 Kranti 删列(BUG-1199) | 完成 | `pl9_reader_export.py` p33 表四列;materials `kranti: blocked` 与质量门 `RESTRICTED_MATERIAL_IDS` 未动 |
| T2 八分法三列(BUG-1200) | 完成,**实现方式偏离任务书** | 见下「偏离」 |
| T3 上升星宿(BUG-1201) | 完成 | `_nakshatra_fields` 九曜 / 计都 / 上升共用;`cache_schema_version` 4→5;指纹 / 绑定核查:只用出生资料(`report-birth-binding`、`secondary-page-data` 的 `profileFingerprint`),报告封面哈希是报告正文;均不受影响 |
| T4 分盘十档庙旺(BUG-1202) | 完成 | `jyotish_engine.varga_dignity_level`(Vimsopaka 与 `/api/varga_full` 共用);新模块 `scripts/varga_dignity.py`,API 主文件只改一行(行数 10923 不变);前端删 `vargaDignity` 与四张表,旧缓存无等级整列隐藏 |
| T5 末尾分盘去重 + 提示前移(BUG-1203) | 完成 | `fencedReportChartIds` 页面与导出共用;提示句由引擎写在 p6-p17 标题下(中英) |
未用让步顺序。
## 偏离任务书:T2 不改 `ashtakavarga.py`
任务书写「`_map_to_houses_values` 增字段」。照做后快速门红 4 条(`test_rectification_validation_integrity_gate`)、`test_sealed_holdout_contract_freshness` 红 3 条:该文件在生时校正冻结身份里(ERR-110 同型;台账新增 ERR-115,是任务书本身的疏漏)。改为 reader 用 `sav.scores` 与 `bav.Lagna.bindus` 拆分,并要求逐宫 `SAV + 上升 BAV == house_scores_full.sav_score` 才填;`ashtakavarga.py` 已回到基线字节,门禁恢复全绿,无需重新冻结。红线 1(`sav_score` 不变)照样满足。
## 既有断言改动(原值 / 新值 / 原因)
| 位置 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `frontend/tests/chart-varga-table.test.tsx` D1 逐字节基线 | 表 == `chart-d1-table-baseline.html` | 表 == 基线把上升行唯一一组「— — — —」换成「Uttara Phalguni / ☉ / 1 / —」;并断言基线里这样的全横杠行只有一处 | BUG-1201:上升三格现在有引擎值;其余逐字节不变 |
| 同文件 D9 行 | `dignity == "own"` / `"debilitated"`;markup `>自宫<`、`>落陷<` | `"OWN_SIGN"` / `"DEBILITATED"`;markup `>入庙(Own Sign)<`、`>落陷(Debilitated)<`,另加入友 / 中性 / 入敌 / 极敌四行 | BUG-1202:十档引擎等级、D1 状态列措辞 |
| 同文件「null 不关列」 | tbody 不含 `同位/入旺/自宫/落陷` | tbody 不含 `同位` 与 `(Exalted|Own Sign|Debilitated|Neutral)` | 同上,标签换了;断言强度不变 |
| `frontend/tests/chart-view-varga-dignity.test.ts` | `vargaDignity` 三档优先级 8 条断言 | `vargaDignityLevel` 接受十档、拒绝旧三档值与非字符串;标签与 D1 措辞一致、键集合 = 十档 | 前端自算函数已删除(不留第二套判定) |
| `tests/test_chart_page_vedic_tables_contract.py` | `test_tables_equal_engine_constants`、`test_every_planet_sign_pair_matches_engine_dignity_level`(钉三档表) | `test_dignity_labels_equal_engine_status_wording`(措辞表 = `DIGNITY_LABELS`,旧四表已不存在)+ `test_d9_projected_dignity_is_the_engine_level`(真实 TS mapper 投影 = 引擎等级,golden 覆盖入庙 / 落陷 / 中性 / 入敌 / 入友) | 同上 |
golden:`frontend/tests/fixtures/chart-view-golden.json` 只新增字段(上升三项、每颗分盘星 `dignity_level`),原值零改动;重录方式写在 `sourceCommand`(`_nakshatra_fields(ascendant.lon)`、`annotate_varga_dignity` 作用于已存位置,即 `/api/chart`、`/api/varga_full` 现在的调用)。
报告大 fixture(`report-reader-main-fictional*.json`、`report-density-fictional-reader.json`)是 9 月真实输出,里面仍是旧表;前端没有断言碰这两张表,未重录(重录会混入其后大量无关引擎变化)。新表由 Python 测试现场渲染断言。
## 验收
| 项 | 结果 |
| --- | --- |
| `tsc --noEmit` | 0 错 |
| `npm run lint` | 0 error(126 warning 均为既有;改动文件单独跑 eslint 0 问题) |
| `npm test`(Node 22) | 4927 项 / 失败 24;基线 `3bbc2daa` 4921 项 / 失败 24;失败清单逐名一致,全为无 Docker 的数据库 / 部署套件;新增 6 项 |
| `next build` | 通过,`/` 为 `○ Static` |
| 首屏 gzip(`index.html` 引用的 29 个脚本 / 样式 gzip-9 求和) | 基线 625,123 → 本分支 626,061,**+0.15%**。基线构建在最后 standalone 复制步因整机磁盘满(ENOSPC)退出,编译与页面生成已完成,`index.html` 与引用文件齐全,数字有效 |
| Python 定向 | `test_report_chart_blank_columns`(新 12 项)、`test_chart_page_vedic_tables_contract`、`test_api_server_security`(新增 1 项)、`test_report_english_edition`、`test_report_reader_main`、`test_report_chart_block`、`test_ashtakavarga_invariants`、`test_api_server_growth_contract`、`test_rectification_validation_integrity_gate`、`test_sealed_holdout_contract_freshness` 全绿 |
| 快速门 `run_quality_gate.py --profile quick` | Python 段 1047 passed / 1 skipped;最后一步 `npm test` 因上述 24 项无 Docker 失败而整体判红,与基线同 |
| 既有红 | `test_dynamic_rectification_fact_priority::test_historical_event_priority_preserves_vimshottari_actor_difference` 在基线 `3bbc2daa` 同样失败(`selection_priority 0.0`),与本轮无关,未处理 |
| Vimsopaka 抽函数 A/B | `PYTHONHASHSEED=0`,3 张虚构盘 `cmd_full_reading` 的 vimsopaka / varga_full / jaimini / 行星 / 上升(除新增星宿字段)基线与本分支逐字节相同 |
## 环境缺口
- 无登录态与 Chrome:真机走查留给产品,清单 `docs/testing/report-chart-blank-columns-20261003.md`。
- 无 Docker:数据库 / 部署套件 24 项按基线逐名比对。
- 整机磁盘曾满(他会话占用):一次快速门因 `/tmp` 建不了目录全部 error(未计入结论),腾出自己的构建产物后重跑得到上表结果。
+1 -1
View File
@@ -412,4 +412,4 @@
| `TASK-yoga-lunar-node-consistency-20261002.md` | `PROGRESS-yoga-lunar-node-consistency-20261002.md` | 月亮前后有星格(Sunaphaa/Anaphaa/Duradhara)计入罗睺计都、孤立格(Kemadruma)不计,泰勒/齐达内卡上同时出现互相矛盾的格局;统一为只计五颗真行星 | **已并入** `TASK-upstream-sync4-20261002.md` T5(交付见 `PROGRESS-upstream-sync4-20261002.md`,`f3ed9509` + `0a4e6d0a`,BUG-1187) | 原分支未使用;在 `codex/upstream-sync4-20261002` 交付 |
| `TASK-upstream-sync4-20261002.md` | `PROGRESS-upstream-sync4-20261002.md` | 上游同步第四轮(对照 yinduzhanxing `0a6696c5`):婚姻计数删关系次数断语、双重过运相位错一宫、Shadbala 四处计算错、格局列表截断与空回退、月亮/太阳类格局只计五星(吸收月亮单)、Arudha 只留 jaimini 一个来源、定位星循环与未标定概率;BUG 从 1183 起 | **待验收**(子代理直接执行完毕,未推送;追加 F3 太阳最低要求;BUG-1183~1191) | 分支 `codex/upstream-sync4-20261002` |
| `TASK-upstream-sync5-20261002.md` | `PROGRESS-upstream-sync5-20261002.md` | 上游同步第五轮:Narayana 默认 K.N. Rao、Chara Karaka 罗睺、燃烧统一、八分法净化、Yogini、诅咒格措辞、Budha-Aditya、Badhaka;生时校正至少 4 件事 3 个领域 | **待验收**(PM 移植 U2–U8 + 子代理 U8 后续 / R1 / 冻结 / 评测 / 回测第七轮,未推送;U1 延后;分数不变不升 scoring-11,policy v3 → v4;BUG-1192~1198) | 分支 `codex/upstream-sync5-20261002` |
| `TASK-report-chart-blank-columns-20261003.md` | `PROGRESS-report-chart-blank-columns-20261003.md` | 报告/星盘页空列:Kranti 删列、八分法宫位表 SAV 标错(实为含上升 386)补三列、上升补星宿、分盘庙旺接引擎十档(有限推翻 BUG-1076/1099 三档)、报告末尾重复分盘去重(BUG-1199~1203);待领取 |
| `TASK-report-chart-blank-columns-20261003.md` | `PROGRESS-report-chart-blank-columns-20261003.md` | 报告/星盘页空列:Kranti 删列、八分法宫位表 SAV 标错(实为含上升 386)补三列、上升补星宿、分盘庙旺接引擎十档(有限推翻 BUG-1076/1099 三档)、报告末尾重复分盘去重(BUG-1199~1203);Claude 直接执行,已合入 staging,待部署核对与真机 |
@@ -0,0 +1,21 @@
# 真机清单 · 报告与星盘页空列(2026-10-03,BUG-1199~1203)
前提:staging 已部署本轮提交(`/api/health` 的 `deployment.gitCommit` 等于合入 SHA)。用自己的账号,不用别人的资料。
## 报告(要**新生成**一份;旧报告内容不会变)
1. 星盘档案 → 本人 → 生成个人报告,等它完成后打开。
2. 目录里「强度、关系与 Ashtakavarga 技术页」下应写「Declination / Speed」,不再有 Kranti。点进去:表只有 Planet / Degree / Declination / Speed 四列,没有整列「-」。
3. 找「Ashtakavarga 完整宫位分数」:五列「House · Sign · SAV · 上升 BAV · SAV + 上升」,每行 SAV + 上升 BAV = 最后一列;最后一行「合计」应是 337 / 49 / 386。SAV 列和上方「Sign | SAV score」表按星座对得上。
4. 目录「出生资料与图盘 → 本命与分盘北印度图盘」:标题下第一句是「分盘对出生时间敏感,原始计算供核对,不单独增加结论的确定性。」
5. 滚到报告最底部:不应再出现「分盘核对」和第二遍 D1 / D2 图。
6. 右上切「English」:第 2–5 步对应英文版同样成立(表头「Lagna BAV」「SAV + Lagna」,合计行「Total」)。
7. 导出 → 「图盘」:导出的图盘数量与修复前一样(每张一次)。
8. 打开一份**以前生成的**报告:能正常打开;若末尾原本有「分盘核对」,它只会在正文缺图时出现。
## 星盘页
9. 打开「星盘」→ 本命表第一行「上升」:星宿、宿主、pada 三格有值(例:狮子 14°32′ 应为 Purva Phalguni、宿主金星符号、pada 1);状态格仍为「—」。
10. 切到 D9:「庙旺」列每颗星都有字(入旺 / 本垣 / 入庙 / 极友 / 入友 / 中性 / 入敌 / 极敌 / 落陷 / 落陷取消之一),写法与 D1「状态」列一样;上升行写「不适用」。同位列不变(只有同位的写「同位」)。
11. 切 D10、D2 各看一眼:庙旺列同样每颗星有字。
12. 手机宽度(或把窗口拉窄)看 D9 表:庙旺列文字变长后表格可横向滚动,不撑破页面。
+3 -3
View File
@@ -653,7 +653,7 @@ Action results and errors never take page layout. They are toasts from `src/lib/
- **Surface:** page floor `--color-canvas-soft`; the Markdown article is a `--color-canvas` sheet with a hairline and `--radius-lg`. Print flattens the sheet, hides chrome and the TOC, and pins the light palette.
- **Print inside the shell:** printing takes the whole document, so the reader ships a second `media="print"` stylesheet that hides the sidebar and the 46px header **and unlocks them**: `.chat-app` / `.chat-panel` are `height: 100%; overflow: hidden`, which would otherwise clip a nine-section report to one page. It lives on the component rather than in `globals.css` so it exists only while the reader is mounted and no other surface has to know about it. The `@page` rule is unchanged.
- **Typography:** Markdown `h2` uses `--type-display-sm` serif at weight 400. `h3` uses `--type-title-md` sans at weight 500. Body is `--type-body-md` at 1.65. Honesty labels (`blocked` / `conflict` / `parameter_sensitive`) stay visible; the reading-nav and quality-matrix sections are not collapsed.
- **Charts:** a rehype pass groups each `####` chart heading (optional note paragraph, then the `jyotish-chart` fence) into `.personal-report-chart-card` and consecutive cards into the existing `.personal-report-chart-grid`. A lone card uses `is-single`. The grid is two columns; print and the 760px breakpoint stay one column. Do not float chart headings or figures, and do not use negative margins to fake columns. Occupants are one-character Chinese names plus integer degree (`日 12°`), with `逆` after the name when retrograde (`土逆 3°`). House corners show rasi numbers 1–12. Chart JSON fences are stripped from the `.md` download. Markdown still uses `react-markdown` + `remark-gfm`, `skipHtml`, and no `rehype-raw`. The article tree is memoized; TOC highlight state lives in `ReportToc`, not in `PersonalReportMarkdownView`.
- **Charts:** a rehype pass groups each `####` chart heading (optional note paragraph, then the `jyotish-chart` fence) into `.personal-report-chart-card` and consecutive cards into the existing `.personal-report-chart-grid`. A lone card uses `is-single`. The grid is two columns; print and the 760px breakpoint stay one column. Do not float chart headings or figures, and do not use negative margins to fake columns. Occupants are one-character Chinese names plus integer degree (`日 12°`), with `逆` after the name when retrograde (`土逆 3°`). House corners show rasi numbers 1–12. Chart JSON fences are stripped from the `.md` download. The chart section opens with one caveat line under its heading ("分盘对出生时间敏感,原始计算供核对,不单独增加结论的确定性。", engine-written, English in the English edition). Each chart is drawn once: the trailing 「分盘核对」 block only draws structured charts the body has no fence for — the export's rule, shared via `fencedReportChartIds` — so a current report has no trailing block at all and an older report keeps only the charts its body lacks (BUG-1203). Markdown still uses `react-markdown` + `remark-gfm`, `skipHtml`, and no `rehype-raw`. The article tree is memoized; TOC highlight state lives in `ReportToc`, not in `PersonalReportMarkdownView`.
- **Accent:** headings stay ink. Dark ink is never a page-scale rule or card edge.
- **Tables:** wide Markdown tables sit in `overflow-x: auto` wrappers; print unwraps them. Report tables (Markdown and fact tables, never chat bubbles) carry a header band of `--color-ink` at 6% and zebra rows at 3%, both mixed into transparency so they work on paper and on the dark sheet; print lightens them to 5% / 2.5%.
- **Width:** 1120px for the reader chrome; the article takes `minmax(0, 1fr)` and the TOC rail `minmax(11rem, 15rem)` to its right. The TOC collapses into a drawer below 860px.
@@ -1152,7 +1152,7 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句
| pada | 1–4,居中 |
| 状态 | 尊贵 / 庙旺等,原样保留,只是挪到最后一列 |
第一行是上升。上升是盘点不是星体,引擎不返回它的星宿 / pada / 宿主,这三格写「—」而不是填一个看起来合理的值。数字列 `tabular-nums`,符号列与宿主列居中。
第一行是上升。星宿 / 宿主 / pada 用引擎按上升经度算出的值,和九曜同一个算法(2026-10-03,BUG-1201;之前引擎漏算,这三格一直是「—」);旧缓存里没有这三项时仍写「—」,页面不自己推算。上升没有尊贵,状态格写「—」。数字列 `tabular-nums`,符号列与宿主列居中。
本轮不新增第二层页签:参照里「宫位 / 大运 / 星力 / 瑜伽」等页签的内容后续另立单。
@@ -1161,7 +1161,7 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句
| 盘型 | 列 | 说明 |
| --- | --- | --- |
| 本命 | 上面八列 | 不变(与基线渲染逐字节相同) |
| D2–D60 | 行星 · 分盘星座(度°分′)· 宫位 · 本命星座 · 庙旺;仅 D9 再有同位 | 宫位从该分盘上升数;庙旺只写「入旺 / 自宫 / 落陷」,其余「—」,上升此格「不适用」。2026-09-29 收口:仅 D9 的 D1↔D9 同座比较显示为同位,其他分盘不显示此列。两列各由服务端能力声明控制,缺声明保守隐藏,不以是否命中推断支持性。**没有星宿 / 宿主 / 足**:那是本命经度的属性。逆行在行星格后标「逆」(分盘不改顺逆)。首行是分盘上升 |
| D2–D60 | 行星 · 分盘星座(度°分′)· 宫位 · 本命星座 · 庙旺;仅 D9 再有同位 | 宫位从该分盘上升数;庙旺是引擎给的十档(入旺 / 本垣 / 入庙 / 极友 / 入友 / 中性 / 入敌 / 极敌 / 落陷 / 落陷取消),措辞与本命「状态」列一字不差(2026-10-03,BUG-1202,取代原先「入旺 / 自宫 / 落陷,其余—」的三档);旧缓存没有引擎等级时整列不显示,页面不自算;上升此格「不适用」。2026-09-29 收口:仅 D9 的 D1↔D9 同座比较显示为同位,其他分盘不显示此列。两列各由服务端能力声明控制,缺声明保守隐藏,不以是否命中推断支持性。**没有星宿 / 宿主 / 足**:那是本命经度的属性。逆行在行星格后标「逆」(分盘不改顺逆)。首行是分盘上升 |
| 月亮盘 | 本命八列 | 宫位从月亮所在星座数;表头「月亮盘 · 以月亮所在星座(X)为第 1 宫」;盘上「As」画在本命上升落的那一宫(`VedicChartSvg` 的 `ascendantHouse`,其它盘默认 1 宫不变) |
| Bhava | 行星 · 黄道经度 · 星座宫 · Bhava 宫 · 换宫;另一张「宫 · 宫头 · 起点(sandhi)」12 行 | Sripati。换宫的行用 `--color-selected` 浅底。拿不到真实 MC 时这一栏走失败句,不画等宫(BUG-1077) |
| 行运 | 行星 · 今天的位置 · 速度 · 落本命 · 本命同星 | 表头写计算时刻「行运 · YYYY-MM-DD 12:00(时区)」,上方一句「以本命上升为第 1 宫,放的是这一天中午的行星位置。岁差和交点与本命相同。」 |
@@ -78,7 +78,8 @@ function PlanetRow({ row }: { row: ChartViewPlanetRow }) {
}
/**
* The ascendant is a chart point: the engine returns no nakshatra for it.
* The ascendant is a chart point: nakshatra, lord and pada come from the
* engine (BUG-1201); it has no dignity, so the status cell stays empty.
* `house` is 1 except on the Moon chart, which counts from the Moon.
*/
function AscendantRow({ row, house = row.house }: { row: ChartViewAscendantRow; house?: number }) {
@@ -566,7 +566,7 @@ export function PersonalReportPage({ reportId, initialLanguage = "zh" }: { repor
<div className="personal-report-reader-body" lang={shown === "en" ? "en" : "zh-CN"}>
{/* Siblings remount per edition under distinct keys; a shared key left the old edition on screen (BUG-1126). */}
<PersonalReportMarkdownView key={`markdown-${shown}`} language={shown} markdown={markdown} drawnThrough={drawnThrough} />
{state.calculationCharts && <ReportCalculationCharts charts={state.calculationCharts} language={shown} />}
{state.calculationCharts && <ReportCalculationCharts charts={state.calculationCharts} markdown={markdown} language={shown} />}
{factTables && <ReportFactTables key={`facts-${shown}`} language={shown} tables={factTables} />}
</div>
</div>
@@ -1,22 +1,34 @@
"use client";
import { memo } from "react";
import { memo, useMemo } from "react";
import type { ChartV1, ReportDocumentV2 } from "@/lib/personal-report-contract";
import { fencedReportChartIds } from "@/lib/report-chart-block";
import { hasRealChartData, VedicChartSvg } from "./vedic-chart-svg";
import type { ReportLanguage } from "@/lib/report-language";
const COPY = {
zh: { heading: "分盘核对", note: "原始计算供核对,分盘对出生时间敏感,不单独增加结论的确定性。" },
en: { heading: "Divisional charts for checking", note: "Raw calculations for checking. Divisional charts are sensitive to birth time and do not by themselves make a conclusion more certain." },
zh: { heading: "分盘核对" },
en: { heading: "Divisional charts for checking" },
} as const;
export const ReportCalculationCharts = memo(function ReportCalculationCharts({ charts, language = "zh" }: { charts: ReportDocumentV2["charts"]; language?: ReportLanguage }) {
/**
* Structured charts the report body did not already draw (BUG-1203): the same
* rule as the export. A current report draws every chart in its body, so this
* renders nothing; an older report without chart fences still gets its charts.
* The birth-time caveat lives under the body's chart heading.
*/
export const ReportCalculationCharts = memo(function ReportCalculationCharts({ charts, markdown, language = "zh" }: {
charts: ReportDocumentV2["charts"];
/** The body shown on the page, whose chart fences are already drawn. */
markdown: string;
language?: ReportLanguage;
}) {
const copy = COPY[language];
const actual = charts.filter(chart => hasRealChartData(chart as ChartV1));
const drawn = useMemo(() => fencedReportChartIds(markdown), [markdown]);
const actual = charts.filter(chart => hasRealChartData(chart as ChartV1) && !drawn.has(chart.id));
if (!actual.length) return null;
return <section className="personal-report-section" aria-label={copy.heading}>
<h2>{copy.heading}</h2>
<p>{copy.note}</p>
<div className="personal-report-chart-grid">
{actual.map(chart => <article className="personal-report-chart-card" key={chart.id}>
<h3>{language === "en" ? `${chart.id} chart` : chart.title}</h3>
+2 -1
View File
@@ -2,6 +2,7 @@ import { z } from "zod";
import { CHART_BIRTH_FAILURE_REASONS } from "./chart-birth-truth.ts";
import { PLANET_GLYPH_KEYS } from "./planet-glyphs.ts";
import { VARGA_DIGNITY_LEVELS } from "./chart-view-varga.ts";
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
@@ -82,7 +83,7 @@ export const chartViewVargaRowSchema = z.object({
house: z.number().int().min(1).max(12),
natalSign: z.string().min(1),
natalSignLabel: z.string().min(1),
dignity: z.enum(["exalted", "own", "debilitated"]).nullable(),
dignity: z.enum(VARGA_DIGNITY_LEVELS).nullable(),
vargottama: z.boolean(),
retrograde: z.boolean(),
});
+10 -10
View File
@@ -23,7 +23,7 @@ import {
signZh,
} from "./chart-view-labels.ts";
import { traditionalSymbol } from "./chart-view-symbols.ts";
import { vargaDignity } from "./chart-view-varga.ts";
import { vargaDignityLevel } from "./chart-view-varga.ts";
import { transitsFromEngine } from "./ephemeris-view.ts";
import { TRANSIT_LOCAL_TIME } from "./transit-chart-body.ts";
import {
@@ -209,9 +209,8 @@ function houseFromSigns(sign: string, ascSign: string): number {
/**
* The divisional chart's own rows: the engine's divisional sign, in-varga
* degree and house (counted from the divisional ascendant), plus the D1 sign
* each body came from. Nothing is recomputed here except the sign-dignity
* lookup, whose tables are the engine's (`chart-view-varga.ts`).
* degree, house (counted from the divisional ascendant) and dignity level,
* plus the D1 sign each body came from. Nothing is recomputed here.
*/
function vargaRows(
ascSign: string,
@@ -236,7 +235,7 @@ function vargaRows(
house: engineHouse !== null && engineHouse >= 1 && engineHouse <= 12 ? Math.trunc(engineHouse) : houseFromSigns(sign, ascSign),
natalSign,
natalSignLabel: signZh(natalSign),
dignity: vargaDignity(name, sign),
dignity: vargaDignityLevel(planet.dignity_level),
vargottama: sign === natalSign,
retrograde: natalRetro.get(name) ?? false,
});
@@ -323,9 +322,10 @@ function buildVargas(
chart: mapped.chart,
...(table ? {
...table,
// A null dignity or false same-sign result is still a valid result.
// Availability follows validated source rows, never positive hits.
supportsDignity: table.rows.length > 0,
// The engine gives every body a level; a row without one is an
// older cached payload, and then the column is hidden (BUG-1202).
supportsDignity: table.rows.length > 0 && table.rows.every((row) => row.dignity !== null),
supportsVargottama: division === 9 && (table.rows.length > 0 || table.ascendant !== undefined),
} : {}),
});
@@ -383,9 +383,9 @@ function planetRows(chart: Record<string, unknown>, vargaPacket: Record<string,
}
/**
* The ascendant as a planet-table row. Sign and degree come straight from the
* engine's `ascendant`; nakshatra, pada and their lord stay null because the
* engine does not return them for a chart point and they must not be guessed.
* The ascendant as a planet-table row, straight from the engine's `ascendant`
* (nakshatra, pada and lord since BUG-1201). An older cached chart without
* them leaves the three cells empty; they are never derived here.
*/
function ascendantRow(chart: Record<string, unknown>) {
const ascendant = record(chart.ascendant);
+26 -24
View File
@@ -1,33 +1,35 @@
import tables from "./vedic-chart-tables.json";
export type VargaDignity = "exalted" | "own" | "debilitated";
/**
* The engine's dignity levels (`jyotish_engine._get_dignity_level`). A
* divisional row carries the level `/api/varga_full` computed with
* `varga_dignity_level`, the Vimsopaka function; the page never recomputes it.
*/
export const VARGA_DIGNITY_LEVELS = [
"EXALTED",
"MOOLATRIKONA",
"OWN_SIGN",
"GREAT_FRIEND",
"FRIEND",
"NEUTRAL",
"ENEMY",
"GREAT_ENEMY",
"DEBILITATED",
"NEECHA_BHANGA",
] as const;
export type VargaDignity = (typeof VARGA_DIGNITY_LEVELS)[number];
const EXALTATION: Readonly<Record<string, string>> = tables.exaltation;
const DEBILITATION: Readonly<Record<string, string>> = tables.debilitation;
const SIGN_LORDS: Readonly<Record<string, string>> = tables.signLords;
const NODE_OWN_SIGN: Readonly<Record<string, string>> = tables.nodeOwnSign;
const VARGA_MEANING: Readonly<Record<string, string>> = tables.vargaMeaning;
/**
* Sign dignity of a body inside a divisional chart.
*
* Same precedence as `jyotish_engine._get_dignity_level` (exalted, then own
* sign, then debilitated) without the parts that need a D1 degree or the whole
* chart: no moolatrikona band, no neecha bhanga, no friendship. The tables are
* the engine's; `tests/test_chart_page_vedic_tables_contract.py` pins them.
*/
export function vargaDignity(planet: string, sign: string): VargaDignity | null {
if (EXALTATION[planet] === sign) return "exalted";
if (SIGN_LORDS[sign] === planet || NODE_OWN_SIGN[planet] === sign) return "own";
if (DEBILITATION[planet] === sign) return "debilitated";
return null;
}
/** Same wording as the D1 "状态" column (`jyotish_engine.DIGNITY_LABELS`). */
export const VARGA_DIGNITY_LABEL: Readonly<Record<VargaDignity, string>> = tables.dignityLabels;
export const VARGA_DIGNITY_LABEL: Record<VargaDignity, string> = {
exalted: "入旺",
own: "自宫",
debilitated: "落陷",
};
export function vargaDignityLevel(value: unknown): VargaDignity | null {
return typeof value === "string" && (VARGA_DIGNITY_LEVELS as readonly string[]).includes(value)
? value as VargaDignity
: null;
}
/** The engine's own one-line use of a D-number chart (`VargaType.meaning`). */
export function vargaMeaning(division: number): string | null {
+13
View File
@@ -203,6 +203,19 @@ export function toNorthIndianChart(block: ReportChartBlock, language: ReportLang
};
}
/**
* Ids of the valid chart fences a report body draws. The reader page and the
* export both skip a structured chart whose id is already here (BUG-1203).
*/
export function fencedReportChartIds(markdown: string): Set<string> {
const ids = new Set<string>();
for (const match of markdown.matchAll(/```jyotish-chart\r?\n([\s\S]*?)```/g)) {
const block = parseReportChartBlock(match[1]);
if (block) ids.add(block.id);
}
return ids;
}
export function stripReportChartBlocks(markdown: string): string {
const stripped = markdown.replace(FENCE_RE, "\n\n");
return stripped.replace(/\n{3,}/g, "\n\n");
+2 -3
View File
@@ -1,7 +1,7 @@
import type { ReportDocumentV2 } from "./personal-report-contract";
import { reportChartExportSvg } from "./report-chart-export";
import { buildLongformOutline, personalReportMarkdownFilename } from "./personal-report-longform-outline";
import { chartAriaLabel, parseReportChartBlock, stripReportChartBlocks, toNorthIndianChart, type ReportChartBlock } from "./report-chart-block";
import { chartAriaLabel, fencedReportChartIds, parseReportChartBlock, stripReportChartBlocks, toNorthIndianChart, type ReportChartBlock } from "./report-chart-block";
import type { ReportFactTable } from "./report-fact-table-schema";
import { factCellText, factStatusLabel } from "./report-fact-table-display";
import type { ReportLanguage } from "./report-language";
@@ -71,12 +71,11 @@ export function buildReportExportBlocks({ markdown, charts = [], factTables = []
for (const section of outline.sections) {
add(`body:${section.id}`, "body", section.title, stripReportChartBlocks(section.sourceMarkdown));
}
const chartIds = new Set<string>();
const chartIds = fencedReportChartIds(projected);
for (const match of projected.matchAll(/```jyotish-chart\r?\n([\s\S]*?)```/g)) {
const block = parseReportChartBlock(match[1]);
if (!block) continue;
const occurrence = blocks.filter(item => item.group === "chart" && item.id.startsWith(`chart:${block.id}:`)).length + 1;
chartIds.add(block.id);
// Only the immediately preceding SVG belongs to this occurrence (D9 may repeat).
// The public projection has already rejected unsafe SVG; never recover raw HTML.
const preceding = projected.slice(0, match.index);
+12 -36
View File
@@ -1,5 +1,5 @@
{
"_comment": "Chart-page reference tables. Mirrors jyotish_engine.SIGN_LORDS / EXALTATION / DEBILITATION, the Rahu-Virgo / Ketu-Pisces own-sign rule in jyotish_engine._get_dignity_level, and divisional_charts_extended.VargaType meanings. tests/test_chart_page_vedic_tables_contract.py fails if either side drifts.",
"_comment": "Engine tables the chart page reads. vargaMeaning = divisional_charts_extended.VargaType meaning; dignityLabels = jyotish_engine.DIGNITY_LABELS (the D1 status wording). Pinned by tests/test_chart_page_vedic_tables_contract.py.",
"vargaMeaning": {
"1": "本命盘",
"2": "财富",
@@ -22,40 +22,16 @@
"45": "全面判断",
"60": "前世业力"
},
"signLords": {
"Aries": "Mars",
"Taurus": "Venus",
"Gemini": "Mercury",
"Cancer": "Moon",
"Leo": "Sun",
"Virgo": "Mercury",
"Libra": "Venus",
"Scorpio": "Mars",
"Sagittarius": "Jupiter",
"Capricorn": "Saturn",
"Aquarius": "Saturn",
"Pisces": "Jupiter"
},
"exaltation": {
"Sun": "Aries",
"Moon": "Taurus",
"Mars": "Capricorn",
"Mercury": "Virgo",
"Jupiter": "Cancer",
"Venus": "Pisces",
"Saturn": "Libra"
},
"debilitation": {
"Sun": "Libra",
"Moon": "Scorpio",
"Mars": "Cancer",
"Mercury": "Pisces",
"Jupiter": "Capricorn",
"Venus": "Virgo",
"Saturn": "Aries"
},
"nodeOwnSign": {
"Rahu": "Virgo",
"Ketu": "Pisces"
"dignityLabels": {
"EXALTED": "入旺(Exalted)",
"MOOLATRIKONA": "本垣(Moolatrikona)",
"OWN_SIGN": "入庙(Own Sign)",
"GREAT_FRIEND": "极友(Great Friend)",
"FRIEND": "入友(Friendly Sign)",
"NEUTRAL": "中性(Neutral)",
"ENEMY": "入敌(Enemy Sign)",
"GREAT_ENEMY": "极敌(Great Enemy)",
"DEBILITATED": "落陷(Debilitated)",
"NEECHA_BHANGA": "落陷取消(Neecha Bhanga)"
}
}
+52 -8
View File
@@ -90,13 +90,34 @@ test("a divisional chart's table carries that chart's signs, degrees and houses"
assert.match(ascD9, /射手/, "D9 ascendant is Sagittarius");
});
test("the D1 table is byte-for-byte the table the page drew before BUG-1076", async () => {
test("the D1 table is byte-for-byte the table the page drew before BUG-1076, ascendant nakshatra aside", async () => {
const view = await vargaView();
const markup = render(view, "D1");
const table = markup.match(/<div class="chart-page-planet-table-wrap">[\s\S]*?<\/table><\/div>/)?.[0];
// Rendered from the baseline commit 5dc977ef with the same golden.
// Rendered from the baseline commit 5dc977ef with the same golden. The only
// change since is BUG-1201: the engine now gives the ascendant its nakshatra
// (Leo 29°23′ = Uttara Phalguni pada 1, lord Sun), where the baseline had dashes.
const baseline = readFileSync(new URL("./fixtures/chart-d1-table-baseline.html", import.meta.url), "utf8");
assert.equal(table, baseline);
const dashes = '<td>—</td><td class="chart-page-planet-mid">—</td><td class="chart-page-planet-mid chart-page-planet-num">—</td><td>—</td>';
const filled = '<td>Uttara Phalguni</td><td class="chart-page-planet-mid"><span class="chart-page-planet-glyph is-sun">☉</span></td>'
+ '<td class="chart-page-planet-mid chart-page-planet-num">1</td><td>—</td>';
assert.equal(baseline.split(dashes).length, 2, "the baseline's ascendant row is the only all-dash row");
assert.equal(table, baseline.replace(dashes, filled));
});
// BUG-1201: the ascendant row's nakshatra cells were always dashes.
test("the D1 ascendant row shows the engine's nakshatra, lord and pada", async () => {
const view = await vargaView();
assert.equal(view.vedic.ascendant?.nakshatra, "Uttara Phalguni");
assert.equal(view.vedic.ascendant?.pada, 1);
const ascendant = row(render(view, "D1"), "上升");
assert.match(ascendant, /<td>Uttara Phalguni<\/td>/);
assert.match(ascendant, /is-sun/, "Uttara Phalguni's lord is the Sun");
// An older cached chart without the fields still renders, with dashes.
const chart = structuredClone(golden.chart);
for (const key of ["nakshatra", "nakshatra_pada", "nakshatra_lord"]) delete (chart.ascendant as Record<string, unknown>)[key];
const old = buildChartView({ chart, varga: null, chara: null, western: null, profile, asOf: "2026-09-15" });
assert.equal(old.vedic.ascendant?.nakshatra, null);
});
test("divisional rows carry engine dignity and vargottama, and drop nakshatra columns", async () => {
@@ -106,18 +127,23 @@ test("divisional rows carry engine dignity and vargottama, and drop nakshatra co
const saturn = d9.rows.find((item) => item.name === "Saturn");
// D1 Saturn is in Capricorn and D9 Saturn is in Capricorn: own sign, vargottama.
assert.equal(saturn?.sign, "Capricorn");
assert.equal(saturn?.dignity, "own");
assert.equal(saturn?.dignity, "OWN_SIGN");
assert.equal(saturn?.vargottama, true);
const sun = d9.rows.find((item) => item.name === "Sun");
assert.equal(sun?.sign, "Libra");
assert.equal(sun?.dignity, "debilitated");
assert.equal(sun?.dignity, "DEBILITATED");
assert.equal(sun?.vargottama, false);
const markup = render(view, "D9");
const header = markup.match(/<thead>([\s\S]*?)<\/thead>/)?.[1] ?? "";
for (const column of ["行星", "分盘星座", "宫位", "本命星座", "庙旺", "同位"]) assert.match(header, new RegExp(`>${column}<`));
assert.doesNotMatch(header, /星宿|宿主|pada/);
assert.match(row(markup, "土星"), />自宫<[\s\S]*>同位</);
assert.match(row(markup, "太阳"), />落陷</);
// Same wording as the D1 status column (BUG-1202), friendship levels included.
assert.match(row(markup, "土星"), />入庙\(Own Sign\)<[\s\S]*>同位</);
assert.match(row(markup, "太阳"), />落陷\(Debilitated\)</);
assert.match(row(markup, "月亮"), />入友\(Friendly Sign\)</);
assert.match(row(markup, "火星"), />中性\(Neutral\)</);
assert.match(row(markup, "水星"), />入敌\(Enemy Sign\)</);
assert.match(row(markup, "木星"), />极敌\(Great Enemy\)</);
assert.match(row(markup, "上升"), />不适用</, "the ascendant has no sign dignity");
// Every divisional chart the engine returned has its own rows, D1 has none.
for (const varga of view.vedic.vargas) {
@@ -181,7 +207,7 @@ test("valid false and null results never turn supported columns off", async () =
copy.ascendant!.vargottama = false;
const markup = tableMarkup(chartViewVargaSchema.parse(copy));
assertColumns(markup, true, true);
assert.doesNotMatch(markup.match(/<tbody>([\s\S]*?)<\/tbody>/)?.[1] ?? "", />同位<|>入旺<|>自宫<|>落陷</);
assert.doesNotMatch(markup.match(/<tbody>([\s\S]*?)<\/tbody>/)?.[1] ?? "", />同位<|\((?:Exalted|Own Sign|Debilitated|Neutral)\)</);
assert.match(row(markup, "上升"), />不适用</);
});
@@ -200,6 +226,24 @@ test("capability schema rejects wrong types and direct cache rendering requires
}
});
// BUG-1202: the dignity column is the engine's; it is never recomputed here.
test("a divisional payload without engine dignity hides the column instead of deriving it", () => {
const varga = structuredClone(golden.varga_full);
for (const item of Object.values(varga.result)) {
for (const planet of Object.values(item.planets)) delete planet.dignity_level;
}
const view = buildChartView({ chart: structuredClone(golden.chart), varga: varga as unknown as Record<string, unknown>, chara: null, western: null, profile, asOf: "2026-09-15" });
const d9 = view.vedic.vargas.find((item) => item.id === "D9")!;
assert.equal(d9.supportsDignity, false);
assert.equal(d9.supportsVargottama, true);
assert.ok(d9.rows!.every((item) => item.dignity === null));
assertColumns(tableMarkup(d9), false, true);
const unknown = structuredClone(golden.varga_full);
unknown.result.D9_Navamsa!.planets.Sun!.dignity_level = "SOMETHING_NEW";
const mixed = buildChartView({ chart: structuredClone(golden.chart), varga: unknown as unknown as Record<string, unknown>, chara: null, western: null, profile, asOf: "2026-09-15" });
assert.equal(mixed.vedic.vargas.find((item) => item.id === "D9")!.supportsDignity, false);
});
test("missing or invalid source positions are not manufactured as negative same-sign results", () => {
const chart = structuredClone(golden.chart);
const varga = structuredClone(golden.varga_full);
+17 -12
View File
@@ -1,19 +1,24 @@
import assert from "node:assert/strict";
import test from "node:test";
import { vargaDignity, vargaMeaning } from "../src/lib/chart-view-varga.ts";
import { VARGA_DIGNITY_LABEL, VARGA_DIGNITY_LEVELS, vargaDignityLevel, vargaMeaning } from "../src/lib/chart-view-varga.ts";
// The tables are pinned to the engine by tests/test_chart_page_vedic_tables_contract.py;
// this pins the precedence (exalted, own, debilitated) the Python test mirrors.
test("sign dignity follows the engine's precedence and node own signs", () => {
assert.equal(vargaDignity("Sun", "Aries"), "exalted");
assert.equal(vargaDignity("Sun", "Leo"), "own");
assert.equal(vargaDignity("Sun", "Libra"), "debilitated");
assert.equal(vargaDignity("Mercury", "Virgo"), "exalted", "exaltation wins over own sign");
assert.equal(vargaDignity("Rahu", "Virgo"), "own");
assert.equal(vargaDignity("Ketu", "Pisces"), "own");
assert.equal(vargaDignity("Rahu", "Taurus"), null, "the engine has no node exaltation");
assert.equal(vargaDignity("Moon", "Leo"), null);
// BUG-1202: the level is the engine's (`/api/varga_full` dignity_level); the
// page only validates it and labels it with the D1 status wording, pinned to
// jyotish_engine.DIGNITY_LABELS by tests/test_chart_page_vedic_tables_contract.py.
test("engine dignity levels are accepted as-is and anything else is unknown", () => {
for (const level of VARGA_DIGNITY_LEVELS) assert.equal(vargaDignityLevel(level), level);
assert.equal(vargaDignityLevel("exalted"), null, "the retired three-level values are not engine levels");
assert.equal(vargaDignityLevel(undefined), null);
assert.equal(vargaDignityLevel(1), null);
});
test("every engine level has the D1 status wording", () => {
assert.equal(VARGA_DIGNITY_LABEL.EXALTED, "入旺(Exalted)");
assert.equal(VARGA_DIGNITY_LABEL.OWN_SIGN, "入庙(Own Sign)");
assert.equal(VARGA_DIGNITY_LABEL.ENEMY, "入敌(Enemy Sign)");
assert.equal(VARGA_DIGNITY_LABEL.NEUTRAL, "中性(Neutral)");
assert.deepEqual(Object.keys(VARGA_DIGNITY_LABEL).sort(), [...VARGA_DIGNITY_LEVELS].sort());
});
test("varga meanings come from the engine table including D40, D45 and D60", () => {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,48 @@
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 { ReportCalculationCharts } from "../src/components/personal-report/report-calculation-charts.tsx";
import { buildReportExportBlocks } from "../src/lib/report-export-blocks.ts";
import { fencedReportChartIds } from "../src/lib/report-chart-block.ts";
import { assembleLongformCharts } from "../src/lib/personal-report-generation.ts";
Object.assign(globalThis, { React });
// Real engine output and its real reader body (fictional chart).
const fixture = JSON.parse(readFileSync(new URL("./fixtures/report-density-fictional-reader.json", import.meta.url), "utf8"));
const engine = JSON.parse(readFileSync(new URL("./fixtures/report-density-fictional-engine.json", import.meta.url), "utf8"));
const charts = assembleLongformCharts(engine, "ev-cover-longform");
function render(markdown: string, language: "zh" | "en" = "zh"): string {
return renderToStaticMarkup(React.createElement(ReportCalculationCharts, { charts, markdown, language }));
}
// BUG-1203: the body already drew these charts; the tail drew them again.
test("a report whose body draws every chart has no trailing chart section", () => {
const drawn = fencedReportChartIds(fixture.markdown);
assert.ok(charts.length > 0);
for (const chart of charts) assert.ok(drawn.has(chart.id), chart.id);
assert.equal(render(fixture.markdown), "");
assert.equal(render(fixture.markdown, "en"), "");
});
test("an older body without chart fences keeps its charts, without the moved caveat", () => {
const markup = render("# 报告\n\n正文");
assert.match(markup, /<h2>分盘核对<\/h2>/);
assert.equal((markup.match(/personal-report-chart-card/g) ?? []).length, charts.filter(chart => chart.houses.some(house => house.occupants.length)).length);
assert.doesNotMatch(markup, /出生时间敏感/);
});
test("the page and the export skip the same charts", () => {
const first = /```jyotish-chart\n[\s\S]*?```/.exec(fixture.markdown)![0];
const drawn = [...fencedReportChartIds(first)];
assert.equal(drawn.length, 1);
const markup = render(first);
const pageIds = charts.filter(chart => !drawn.includes(chart.id) && chart.houses.some(house => house.occupants.length)).map(chart => chart.id);
assert.equal((markup.match(/personal-report-chart-card/g) ?? []).length, pageIds.length);
const exported = buildReportExportBlocks({ markdown: first, charts }).filter(block => block.group === "chart" && !/^chart:[^:]+:\d+$/.test(block.id)); // fence charts carry an occurrence suffix
assert.deepEqual(exported.map(block => block.id.replace(/^chart:/, "")), pageIds);
});
+2 -2
View File
@@ -2575,7 +2575,7 @@ def _vedastro_runtime_fingerprint() -> dict:
def _build_api_chart_cache_payload(body: dict) -> dict:
return {
'cache_schema_version': 4,
'cache_schema_version': 5,
'birth': {
'year': body.get('year'),
'month': body.get('month'),
@@ -7820,7 +7820,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM
result = {}
for varga in selected:
key = f'D{varga.division}_{varga.varga_name}'
result[key] = calc._calculate_single_varga(varga, planet_lons, asc_lon)
result[key] = _load_local_module('varga_dignity').annotate_varga_dignity(calc._calculate_single_varga(varga, planet_lons, asc_lon))
except KeyError as e:
raise BadRequest('unsupported standard division; use custom mode for arbitrary D-N') from e
except ValueError as e:
+33 -12
View File
@@ -701,6 +701,20 @@ def _get_dignity_level(planet, sign, deg_in_sign=None, planets_data=None):
return 'NEUTRAL'
def varga_dignity_level(planet, varga_planets):
"""Dignity of a body inside a divisional chart (Vimsopaka and the chart page alike).
The temporary half of the compound relationship is read from that varga's
own positions, as the Vimsopaka path always has. Returns None when the body
has no usable sign.
"""
pinfo = varga_planets.get(planet) if isinstance(varga_planets, dict) else None
if not isinstance(pinfo, dict) or pinfo.get('sign') not in SIGN_LORDS:
return None
deg_in_sign = pinfo.get('degree_in_sign', pinfo.get('degree', 0) % 30)
return _get_dignity_level(planet, pinfo['sign'], deg_in_sign, _build_dignity_context(varga_planets))
def _get_planet_status_label(planet, sign, deg_in_sign=None, planets_data=None):
"""Return the user-facing D1 dignity label for chart output."""
return DIGNITY_LABELS.get(_get_dignity_level(planet, sign, deg_in_sign, planets_data), '中性')
@@ -12228,6 +12242,15 @@ def _load_strict_evidence_collector():
# ============================================================================
# 公共星盘计算(供 chart/shadbala/ashtakavarga 共用,v3.4提取)
# ============================================================================
def _nakshatra_fields(sidereal_lon):
"""Nakshatra, pada and lord of a sidereal longitude — planets and the ascendant alike."""
nak_span = 360.0 / 27
lon = sidereal_lon % 360
pada = int((lon % nak_span) / (nak_span / 4)) + 1
name, lord, _ = NAKSHATRA_LIST[int(lon / nak_span) % 27]
return {"nakshatra": name, "nakshatra_pada": pada, "nakshatra_lord": lord}
def compute_chart_data(year, month, day, hour, minute, lat, lon, tz, node_mode='mean', second=0, ayanamsa_name=None):
"""计算星盘核心数据,返回 (result_dict, asc_idx, jd, ayanamsa)。node_mode: mean|true。"""
if not HAS_SWE:
@@ -12264,7 +12287,8 @@ def compute_chart_data(year, month, day, hour, minute, lat, lon, tz, node_mode='
"degree_in_sign": round(deg_in_sign, 4),
"degree_in_sign_raw": deg_in_sign,
"lon": round(asc_deg, 4),
"lord": SIGN_LORDS[asc_sign]}
"lord": SIGN_LORDS[asc_sign],
**_nakshatra_fields(asc_deg)}
for i in range(12):
c = (asc_lon[i] - ayanamsa) % 360
@@ -12273,7 +12297,6 @@ def compute_chart_data(year, month, day, hour, minute, lat, lon, tz, node_mode='
"cusp_sign_cn": SIGNS_CN[SIGNS[si]], "cusp_degree": round(c, 4),
"lord": SIGN_LORDS[SIGNS[si]]}
nak_span = 360.0 / 27
planets_swe = {**BASE_PLANETS_SWE, 'Rahu': node_pid}
calc_flags = getattr(swe, 'FLG_SWIEPH', 2)
for pname, pid in planets_swe.items():
@@ -12283,26 +12306,22 @@ def compute_chart_data(year, month, day, hour, minute, lat, lon, tz, node_mode='
si = int(lon_p / 30); d_in_s = lon_p - si * 30; sign = SIGNS[si]
retro = spd < 0
house = ((si - asc_idx) % 12) + 1
ni = int(lon_p / nak_span); pada = int((lon_p % nak_span) / (nak_span / 4)) + 1
nak_n, nak_l, _ = NAKSHATRA_LIST[ni % 27]
result["planets"][pname] = {
"sign": sign, "sign_cn": SIGNS_CN[sign], "degree": round(lon_p, 4),
"degree_raw": lon_p,
"degree_in_sign": round(d_in_s, 4), "degree_in_sign_raw": d_in_s,
"house": house, "status": "",
"retrograde": retro, "speed": round(spd, 6),
"nakshatra": nak_n, "nakshatra_pada": pada, "nakshatra_lord": nak_l}
**_nakshatra_fields(lon_p)}
if pname == 'Rahu':
klon = (lon_p + 180) % 360; ksi = int(klon / 30); kd = klon - ksi * 30
kni = int(klon / nak_span); kp = int((klon % nak_span) / (nak_span / 4)) + 1
kn, kl, _ = NAKSHATRA_LIST[kni % 27]
result["planets"]["Ketu"] = {
"sign": SIGNS[ksi], "sign_cn": SIGNS_CN[SIGNS[ksi]],
"degree": round(klon, 4), "degree_raw": klon,
"degree_in_sign": round(kd, 4), "degree_in_sign_raw": kd,
"house": ((ksi - asc_idx) % 12) + 1, "status": "",
"retrograde": True, "speed": round(spd, 6),
"nakshatra": kn, "nakshatra_pada": kp, "nakshatra_lord": kl}
**_nakshatra_fields(klon)}
except Exception as e:
result["planets"][pname] = {"error": str(e)}
@@ -15689,10 +15708,12 @@ def cmd_full_reading(args):
# 不使用 _dignity 字典(只有 Exalted/Debilitated/Own Sign)
pinfo = vdata.get(pn, {})
if isinstance(pinfo, dict) and 'sign' in pinfo:
dignity_context = planets if div == 1 else _build_dignity_context(vdata)
dl_key = _get_dignity_level(pn, pinfo['sign'],
pinfo.get('degree_in_sign', pinfo.get('degree', 0) % 30),
dignity_context)
if div == 1:
dl_key = _get_dignity_level(pn, pinfo['sign'],
pinfo.get('degree_in_sign', pinfo.get('degree', 0) % 30),
planets)
else:
dl_key = varga_dignity_level(pn, vdata)
planet_vargas_input[pn][vt] = dignity_map.get(dl_key, DignityLevel.NEUTRAL)
elif div == 1:
# D1 直接用本命盘数据
+3 -1
View File
@@ -35,6 +35,8 @@ _PHRASES: dict[str, str] = {
"特殊上升与派生点补充": "Special Lagnas and Points (supplement)",
"敏感点与特殊落点": "Sensitive Points and Special Placements",
"本命与分盘北印度图盘": "Natal and Divisional Charts (North Indian)",
"分盘对出生时间敏感,原始计算供核对,不单独增加结论的确定性。":
"Divisional charts are sensitive to birth time. The raw calculations are here for checking and do not by themselves make a conclusion more certain.",
" 数值位置表": " Position Table",
"Shadbala / Bhava Bala 摘要": "Shadbala / Bhava Bala Summary",
"Avastha 行星状态": "Avastha Planetary States",
@@ -84,7 +86,7 @@ _PHRASES: dict[str, str] = {
"Sign lord, star lord, sub lord and sub-sub lord: the rulers of the planet's sign, nakshatra and KP subdivisions. Strength ratio: Shadbala against the required strength; 1 or above meets it.",
"| 对象 | 落座 | 落座度数 | 黄经 | 整宫宫位 |": "| Body | Sign | Degree in sign | Longitude | Whole-sign house |",
"| House | Sign | Lord | Score | 关键因素 |": "| House | Sign | Lord | Score | Key factors |",
"| House | Sign | SAV | BAV/承接 | Full with Lagna |": "| House | Sign | SAV | BAV / carry | Full with Lagna |",
"| 合计 | - |": "| Total | - |",
"| 序号 | 主运 | 起始 | 结束 | 年数 |": "| No. | Mahadasha | Start | End | Years |",
"| Vimshottari AD | 主运 | 子运 | 起始 | 结束 |": "| Vimshottari AD | Mahadasha | Antardasha | Start | End |",
"| Vimshottari PD | 主运 | 子运 | 次子运 | 起始 | 结束 |": "| Vimshottari PD | Mahadasha | Antardasha | Pratyantardasha | Start | End |",
+35 -12
View File
@@ -1008,7 +1008,7 @@ def render_pl9_parity_markdown(packet: dict) -> str:
]
ready_natal_specs = [spec for spec in natal_specs if spec[2]]
if ready_natal_specs:
lines.extend(['### p6-p17 本命与分盘北印度图盘', ''])
lines.extend(['### p6-p17 本命与分盘北印度图盘', '', '分盘对出生时间敏感,原始计算供核对,不单独增加结论的确定性。', ''])
for page, label, _ready in ready_natal_specs:
lines.extend([f'##### p{page} · {label}', ''])
# The original PL9 charts are visual, but their sign placements are
@@ -1140,12 +1140,13 @@ def render_pl9_parity_markdown(packet: dict) -> str:
)
lines.append('')
lines.extend([
'### p33 Declination / Kranti / Speed',
'### p33 Declination / Speed',
'',
'| Planet | Degree | Declination | Kranti | Speed |',
'|--------|--------|-------------|--------|-------|',
'| Planet | Degree | Declination | Speed |',
'|--------|--------|-------------|-------|',
])
kranti_planets = _dict(_dict(strengths.get('kranti_local')).get('planets'))
# Kranti has no local producer (materials.kranti stays blocked), so
# the reader shows no Kranti column rather than a column of dashes.
speed_planets = _dict(_dict(strengths.get('speed_display_local')).get('planets'))
for planet in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn'):
row = _dict(shadbala_planets.get(planet))
@@ -1153,11 +1154,10 @@ def render_pl9_parity_markdown(packet: dict) -> str:
continue
kala = _dict(row.get('kala_bala'))
planet_row = _dict((packet.get('core_chart') or {}).get('planets', {}).get(planet)) if isinstance((packet.get('core_chart') or {}).get('planets'), dict) else {}
kranti_degrees = _dict(kranti_planets.get(planet)).get('kranti_degrees')
speed_dms = _dict(speed_planets.get(planet)).get('speed_dms') or _num(planet_row.get('speed'), 6)
lines.append(
f"| {_cell(_token(planet))} | {_cell(_degree(planet_row.get('degree_in_sign') or planet_row.get('longitude')))} | "
f"{_cell(_degree(kala.get('declination')))} | {_cell(_degree(kranti_degrees))} | {_cell(speed_dms)} |"
f"{_cell(_degree(kala.get('declination')))} | {_cell(speed_dms)} |"
)
lines.append('')
avasthas = _dict(advanced.get('avasthas'))
@@ -1329,15 +1329,38 @@ def render_pl9_parity_markdown(packet: dict) -> str:
lines.extend([
'### p53 Ashtakavarga 完整宫位分数',
'',
'| House | Sign | SAV | BAV/承接 | Full with Lagna |',
'|-------|------|-----|----------|-----------------|',
'| House | Sign | SAV | Lagna BAV | SAV + Lagna |',
'|-------|------|-----|-----------|-------------|',
])
# `house_scores_full[*].sav_score` is the with-Lagna total (386
# scale), never plain SAV (BUG-1200). Split it from the engine's
# per-sign SAV and the Lagna BAV; ashtakavarga.py stays untouched
# because it belongs to the frozen rectification identity.
sign_list = list(globals().get('SIGNS', []))
lagna_bindus = _dict(_dict(ashtakavarga.get('bav')).get('Lagna')).get('bindus')
splits = []
for row in full_rows:
sign = row.get('sign')
sav_value = sav_scores.get(sign)
lagna_value = None
if isinstance(lagna_bindus, list) and len(lagna_bindus) == 12 and sign in sign_list:
lagna_value = lagna_bindus[sign_list.index(sign)]
consistent = (
isinstance(sav_value, int) and isinstance(lagna_value, int)
and sav_value + lagna_value == row.get('sav_score')
)
splits.append((sav_value, lagna_value) if consistent else None)
split = all(splits)
for row, parts in zip(full_rows, splits):
sav_value, lagna_value = parts if split else (None, None)
lines.append(
f"| {_cell(row.get('house'))} | {_cell(_token(row.get('sign')))} | "
f"{_cell(row.get('sav') or row.get('sav_score') or row.get('score'))} | "
f"{_cell(row.get('bav') or row.get('bav_score') or row.get('bindus'))} | "
f"{_cell(row.get('full_with_lagna') or row.get('full_with_lagna_house_score'))} |"
f"{_cell(sav_value)} | {_cell(lagna_value)} | {_cell(row.get('sav_score'))} |"
)
if split:
lines.append(
f"| 合计 | - | {sum(parts[0] for parts in splits)} | "
f"{sum(parts[1] for parts in splits)} | {sum(row['sav_score'] for row in full_rows)} |"
)
lines.append('')
bav = _dict(ashtakavarga.get('bav'))
+24
View File
@@ -0,0 +1,24 @@
"""Engine dignity on each body of a `/api/varga_full` divisional chart (BUG-1202).
The chart page's divisional tables show the same ten-level dignity as the D1
"状态" column. The level is `jyotish_engine.varga_dignity_level`, the function
the Vimsopaka path uses, so the page never carries a second rule set.
"""
from __future__ import annotations
from jyotish_engine import varga_dignity_level
def annotate_varga_dignity(varga: dict) -> dict:
"""Add `dignity_level` to every body of one `_calculate_single_varga` result."""
planets = varga.get("planets") if isinstance(varga, dict) else None
if not isinstance(planets, dict):
return varga
for name, row in planets.items():
if not isinstance(row, dict):
continue
level = varga_dignity_level(name, planets)
if level:
row["dignity_level"] = level
return varga
+24
View File
@@ -3413,6 +3413,30 @@ def test_varga_full_endpoint_returns_extended_standard_divisions() -> None:
assert result['result']['D81_Navamsa-Navamsa']['planets']['Moon']['house'] in range(1, 13)
def test_varga_full_standard_divisions_carry_the_vimsopaka_dignity_level() -> None:
"""BUG-1202: the chart page shows this level; it is the Vimsopaka function's."""
import jyotish_engine
from varga import calc_all_vargas
handler = _handler()
planets = _sample_planets()
result = handler._compute_varga_full({
'planets': planets,
'ascendant': {'lon': 92.0},
'divisions': ['D9', 'D10'],
})
d9 = result['result']['D9_Navamsa']['planets']
assert {name: row['dignity_level'] for name, row in d9.items()} == {
name: jyotish_engine.varga_dignity_level(name, d9) for name in d9
}
assert all(row['dignity_level'] in jyotish_engine.DIGNITY_LABELS for row in d9.values())
# The Vimsopaka path reads the same positions from varga.calc_all_vargas.
vimsopaka_d9 = calc_all_vargas({name: row['lon'] for name, row in planets.items()}, 92.0, None)['D9_Navamsa']
for name, row in d9.items():
assert vimsopaka_d9[name]['sign'] == row['sign'], name
assert jyotish_engine.varga_dignity_level(name, vimsopaka_d9) == row['dignity_level'], name
def test_varga_full_endpoint_supports_custom_and_composite_modes() -> None:
handler = _handler()
custom = handler._compute_varga_full({
+23 -31
View File
@@ -1,9 +1,9 @@
"""The chart page's dignity and varga-meaning tables must be the engine's.
`frontend/src/lib/vedic-chart-tables.json` is read by the chart-page mapper so
the divisional tables can show 入旺 / 自宫 / 落陷 without a second hand-written
rule set. This test pins it to the constants `jyotish_engine._get_dignity_level`
uses and to `divisional_charts_extended.VargaType`.
`frontend/src/lib/vedic-chart-tables.json` is read by the chart page: varga
meanings (`divisional_charts_extended.VargaType`) and the dignity wording
(`jyotish_engine.DIGNITY_LABELS`). The dignity level itself comes from
`/api/varga_full` (`varga_dignity_level`, BUG-1202); the page never derives it.
"""
from __future__ import annotations
@@ -27,33 +27,11 @@ TABLES = json.loads((ROOT / "frontend/src/lib/vedic-chart-tables.json").read_tex
PLANETS = ["Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu"]
def _table_dignity(planet: str, sign: str) -> str | None:
"""Same precedence as `vargaDignity` in frontend/src/lib/chart-view-varga.ts."""
if TABLES["exaltation"].get(planet) == sign:
return "EXALTED"
if TABLES["signLords"].get(sign) == planet or TABLES["nodeOwnSign"].get(planet) == sign:
return "OWN_SIGN"
if TABLES["debilitation"].get(planet) == sign:
return "DEBILITATED"
return None
def test_tables_equal_engine_constants() -> None:
assert TABLES["signLords"] == jyotish_engine.SIGN_LORDS
assert TABLES["exaltation"] == jyotish_engine.EXALTATION
assert TABLES["debilitation"] == jyotish_engine.DEBILITATION
def test_every_planet_sign_pair_matches_engine_dignity_level() -> None:
for planet in PLANETS:
for sign in jyotish_engine.SIGN_LORDS:
# No degree and no chart context: the varga table shows the sign
# dignity only (no moolatrikona band, no neecha bhanga, no friendship).
engine = jyotish_engine._get_dignity_level(planet, sign)
expected = engine if engine in {"EXALTED", "OWN_SIGN", "DEBILITATED"} else None
if engine == "NEECHA_BHANGA":
expected = "DEBILITATED"
assert _table_dignity(planet, sign) == expected, (planet, sign, engine)
def test_dignity_labels_equal_engine_status_wording() -> None:
# BUG-1202: divisional rows carry the engine's level and the page labels it
# with the D1 status wording; the three-level page table is retired.
assert TABLES["dignityLabels"] == jyotish_engine.DIGNITY_LABELS
assert {"signLords", "exaltation", "debilitation", "nodeOwnSign"}.isdisjoint(TABLES)
def test_varga_meanings_equal_engine_varga_types() -> None:
@@ -131,3 +109,17 @@ def test_d9_projected_ascendant_matches_native_summary(d9_projection: tuple[dict
assert projected["ascendant"]["sign"] == native["lagna"]
assert projected["ascendant"]["natalSign"] == golden["chart"]["ascendant"]["sign"]
assert projected["ascendant"]["vargottama"] is native["lagna_vargottama"]
def test_d9_projected_dignity_is_the_engine_level(d9_projection: tuple[dict, dict]) -> None:
golden, projected = d9_projection
d9_planets = golden["varga_full"]["result"]["D9_Navamsa"]["planets"]
levels = set()
for row in projected["rows"]:
expected = jyotish_engine.varga_dignity_level(row["name"], d9_planets)
assert expected is not None
assert row["dignity"] == expected == d9_planets[row["name"]]["dignity_level"]
levels.add(expected)
# The golden must show that friendship levels reach the page, not only
# exalted / own / debilitated.
assert {"OWN_SIGN", "DEBILITATED", "NEUTRAL", "ENEMY", "FRIEND"} <= levels
+130
View File
@@ -0,0 +1,130 @@
"""Blank report and chart columns (TASK-report-chart-blank-columns-20261003).
BUG-1199 Kranti column with no producer; BUG-1200 Ashtakavarga house table
labelled the with-Lagna total as SAV (split in the reader: ashtakavarga.py is
in the frozen rectification identity and stays byte-identical); BUG-1201 ascendant without nakshatra;
BUG-1203 the divisional-chart caveat moved to the chart section. Every chart
here is fictional.
"""
from __future__ import annotations
import copy
import json
import re
from pathlib import Path
import pytest
from scripts.ashtakavarga import BAV_TOTALS, EXPECTED_SAV_TOTAL, SIGNS
from scripts.jyotish_engine import NAKSHATRA_LIST, _nakshatra_fields, compute_chart_data
from scripts.pl9_reader_export import _pl9_export_markdown_for_edition
from tests.test_report_english_edition import CASES, _packet
ROOT = Path(__file__).resolve().parents[1]
CAVEAT_ZH = "分盘对出生时间敏感,原始计算供核对,不单独增加结论的确定性。"
def _independent_nakshatra(lon: float) -> tuple[str, int, str]:
minutes = (lon % 360) * 60
index = int(minutes // 800) # 13°20′ = 800′
pada = int((minutes % 800) // 200) + 1 # 3°20′ = 200′
name, lord, _ = NAKSHATRA_LIST[index]
return name, pada, lord
@pytest.mark.parametrize("lon,expected", [
(0.0, ("Ashwini", 1, "Ketu")),
(13.33, ("Ashwini", 4, "Ketu")),
(13.34, ("Bharani", 1, "Venus")),
(134.53, ("Purva Phalguni", 1, "Venus")),
(149.3994, ("Uttara Phalguni", 1, "Sun")),
(359.99, ("Revati", 4, "Mercury")),
(360.0, ("Ashwini", 1, "Ketu")),
])
def test_nakshatra_fields_at_boundaries(lon, expected) -> None:
fields = _nakshatra_fields(lon)
assert (fields["nakshatra"], fields["nakshatra_pada"], fields["nakshatra_lord"]) == expected
def test_chart_ascendant_carries_its_nakshatra_and_planets_are_unchanged() -> None:
golden = json.loads((ROOT / "frontend/tests/fixtures/chart-view-golden.json").read_text(encoding="utf-8"))
chart, *_ = compute_chart_data(1990, 6, 15, 12, 0, 39.9042, 116.4074, 8.0, ayanamsa_name="raman")
asc = chart["ascendant"]
assert (asc["nakshatra"], asc["nakshatra_pada"], asc["nakshatra_lord"]) == _independent_nakshatra(asc["lon"])
assert {k: asc[k] for k in ("nakshatra", "nakshatra_pada", "nakshatra_lord")} == {
k: golden["chart"]["ascendant"][k] for k in ("nakshatra", "nakshatra_pada", "nakshatra_lord")
}
for name, planet in chart["planets"].items():
stored = golden["chart"]["planets"][name]
assert (planet["nakshatra"], planet["nakshatra_pada"], planet["nakshatra_lord"]) == (
stored["nakshatra"], stored["nakshatra_pada"], stored["nakshatra_lord"]), name
@pytest.fixture(scope="module")
def editions() -> tuple[str, str, dict]:
packet = _packet(CASES["day"])
zh = _pl9_export_markdown_for_edition(copy.deepcopy(packet), "reader_main")
english = copy.deepcopy(packet)
english["report_language"] = "en"
return zh, _pl9_export_markdown_for_edition(english, "reader_main"), packet
def _table_after(markdown: str, heading: str) -> list[list[str]]:
lines = markdown.splitlines()
start = next(i for i, line in enumerate(lines) if line.startswith("### ") and heading in line)
rows = []
for line in lines[start + 1:]:
if line.startswith("### ") or (rows and not line.startswith("|")):
break
if line.startswith("|") and not set(line) <= set("|- "):
rows.append([cell.strip() for cell in line.strip("|").split("|")])
return rows
def test_declination_table_has_no_kranti_column(editions) -> None:
for markdown, heading in ((editions[0], "Declination / Speed"), (editions[1], "Declination / Speed")):
assert "Kranti" not in markdown
rows = _table_after(markdown, heading)
assert rows[0] == ["Planet", "Degree", "Declination", "Speed"]
assert len(rows) == 8 and all(len(row) == 4 and "-" not in row for row in rows)
def test_ashtakavarga_house_table_shows_three_real_columns(editions) -> None:
zh, en, packet = editions
expected_sav = packet["worksheets"]["strengths_and_scores"]["ashtakavarga"]["sav"]["scores"]
for markdown, heading, header, total in (
(zh, "Ashtakavarga 完整宫位分数", ["House", "Sign", "SAV", "上升 BAV", "SAV + 上升"], "合计"),
(en, "Ashtakavarga Full House Scores", ["House", "Sign", "SAV", "Lagna BAV", "SAV + Lagna"], "Total"),
):
rows = _table_after(markdown, heading)
assert rows[0] == header
body, totals = rows[1:13], rows[13]
for row in body:
sav, lagna, full = (int(cell) for cell in row[2:])
assert sav + lagna == full
assert totals == [total, "-", str(EXPECTED_SAV_TOTAL), str(BAV_TOTALS["Lagna"]), str(EXPECTED_SAV_TOTAL + BAV_TOTALS["Lagna"])]
# SAV equals the per-sign SAV table, Lagna BAV the Lagna bindus, and the last
# column the engine's `house_scores_full` total that rectification reads.
ashtakavarga = packet["worksheets"]["strengths_and_scores"]["ashtakavarga"]
full = {row["sign"]: row["sav_score"] for row in ashtakavarga["house_scores_full"].values()}
for row in _table_after(en, "Ashtakavarga Full House Scores")[1:13]:
assert int(row[2]) == expected_sav[row[1]]
assert int(row[3]) == ashtakavarga["bav"]["Lagna"]["bindus"][SIGNS.index(row[1])]
assert int(row[4]) == full[row[1]]
def test_ashtakavarga_house_table_never_labels_the_total_as_sav(editions) -> None:
packet = copy.deepcopy(editions[2])
del packet["worksheets"]["strengths_and_scores"]["ashtakavarga"]["bav"]["Lagna"]
rows = _table_after(_pl9_export_markdown_for_edition(packet, "reader_main"), "Ashtakavarga 完整宫位分数")
assert len(rows) == 13, "no total row without the split"
for row in rows[1:]:
assert row[2:4] == ["-", "-"]
assert row[4].isdigit()
def test_chart_section_carries_the_divisional_caveat(editions) -> None:
zh, en, _ = editions
assert re.search(r"### 本命与分盘北印度图盘\n\n" + re.escape(CAVEAT_ZH) + r"\n", zh)
assert re.search(r"### Natal and Divisional Charts \(North Indian\)\n\nDivisional charts are sensitive to birth time\.", en)