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
+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)