New reports draw one north-Indian chart for each ready divisional in the body, so the trailing duplicate section drops. Chinese full-data prose no longer glues English onto Han or leaves listed, background, or native. The birth table starts with the ascendant. The D9 same-sign column says yes or no. Self-test: tsc exit 0; npm run lint exit 0 (0 errors, 125 existing warnings); three frontend test files 39 pass, 0 fail, 0 cancelled; pytest test_report_chart_blank_columns.py 16 passed; seven related report files 88 passed, exit 0; privacy markers 63 passed. Co-Authored-By: Grok (agent loop)
270 lines
13 KiB
Python
270 lines
13 KiB
Python
"""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), "full_data")
|
||
english = copy.deepcopy(packet)
|
||
english["report_language"] = "en"
|
||
return zh, _pl9_export_markdown_for_edition(english, "full_data"), 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:
|
||
# 原值:两版标题都是 Declination / Speed,表头 Planet/Degree/Declination/Speed
|
||
# 新值:中文标题「赤纬 / 速度」,表头 行星/度数/赤纬/速度;英文保持原文
|
||
# 原因:完整数据版中文清理翻译这四列。Kranti 列仍然不出现
|
||
for markdown, heading, header in (
|
||
(editions[0], "赤纬 / 速度", ["行星", "度数", "赤纬", "速度"]),
|
||
(editions[1], "Declination / Speed", ["Planet", "Degree", "Declination", "Speed"]),
|
||
):
|
||
assert "Kranti" not in markdown
|
||
rows = _table_after(markdown, heading)
|
||
assert rows[0] == header
|
||
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"]
|
||
# BUG-1209 (乙8): a Sodhita SAV column comes first and the raw columns are
|
||
# labelled Raw. Was: ["House", "Sign", "SAV", "Lagna BAV", "SAV + Lagna"]
|
||
# with the raw cells at indexes 2..4 (now 3..5).
|
||
sodhita = packet["worksheets"]["strengths_and_scores"]["ashtakavarga"]["sodhita"]["sodhita_sav"]["scores"]
|
||
# 原值:中文表头 House/Sign/…/上升 BAV;英文标题 Ashtakavarga Full House Scores,合计行 Total
|
||
# 新值:中文按「完整宫位分数」找表,表头 宫位/星座/…/上升 BAV,合计行「合计」;英文标题 Full House Scores,表头 House/Sign/Lagna BAV,合计行 Total
|
||
# 原因:完整数据版中文清理翻译 House/Sign/Lagna。英文先译整节标题,并把「合计」写成 Total,避免单字清理把标题拆碎或把合计留成汉字
|
||
for markdown, heading, header, total in (
|
||
(zh, "完整宫位分数", ["宫位", "星座", "Sodhita SAV", "Raw SAV", "上升 BAV", "Raw SAV + 上升"], "合计"),
|
||
(en, "Full House Scores", ["House", "Sign", "Sodhita SAV", "Raw SAV", "Lagna BAV", "Raw 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[3:])
|
||
assert sav + lagna == full
|
||
assert int(row[2]) <= sav
|
||
# 原值:合计行星座格是 "-"
|
||
# 新值:星座格是 "-"、"未列" 或 "not listed";后面四格数字不变
|
||
# 原因:完整数据版清理器把空位写成「未列」。Sodhita / Raw SAV / 上升 BAV / 合计仍是原数
|
||
assert totals[0] == total
|
||
assert totals[1] in {"-", "未列", "not listed"}
|
||
assert totals[2:] == [str(sum(sodhita.values())), 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, "Full House Scores")[1:13]:
|
||
assert int(row[2]) == sodhita[row[1]]
|
||
assert int(row[3]) == expected_sav[row[1]]
|
||
assert int(row[4]) == ashtakavarga["bav"]["Lagna"]["bindus"][SIGNS.index(row[1])]
|
||
assert int(row[5]) == 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, "full_data"), "Ashtakavarga 完整宫位分数")
|
||
assert len(rows) == 13, "no total row without the split"
|
||
for row in rows[1:]:
|
||
# BUG-1209: raw SAV / Lagna BAV moved from [2:4] to [3:5]; Sodhita at [2].
|
||
assert row[2].isdigit()
|
||
# 原值:缺数两格都是 "-"
|
||
# 新值:缺数格是 "-" 或「未列」,且不是数字、也不标成 SAV
|
||
# 原因:完整数据版清理器会把一部分空位写成「未列」
|
||
assert row[3] in {"-", "未列"} and row[4] in {"-", "未列"}
|
||
assert not row[3].isdigit() and not row[4].isdigit()
|
||
assert row[5].isdigit()
|
||
|
||
|
||
_CALCULATION_CHART_IDS = {
|
||
"D1", "D2", "D3", "D4", "D7", "D9", "D10", "D11", "D12",
|
||
"D16", "D20", "D24", "D27", "D30", "D40", "D60",
|
||
}
|
||
|
||
|
||
def _section_until_next_h2(markdown: str, heading: str) -> str:
|
||
start = markdown.index(heading)
|
||
lines = [heading]
|
||
for line in markdown[start + len(heading):].splitlines():
|
||
if line.startswith("## "):
|
||
break
|
||
lines.append(line)
|
||
return "\n".join(lines)
|
||
|
||
|
||
def test_full_data_chart_section_draws_each_ready_chart_once(editions) -> None:
|
||
from scripts.report_chart_block import FENCE_RE
|
||
zh, en, _packet = editions
|
||
for markdown, heading in (
|
||
(zh, "### 本命与分盘北印度图盘"),
|
||
(en, "### Natal and Divisional North Indian Charts"),
|
||
):
|
||
section = _section_until_next_h2(markdown, heading)
|
||
blocks = []
|
||
for match in FENCE_RE.finditer(section):
|
||
block = json.loads(match.group(1))
|
||
assert block["layout"] == "north"
|
||
blocks.append(block)
|
||
ids = [block["id"] for block in blocks]
|
||
assert ids
|
||
assert len(ids) == len(set(ids))
|
||
assert _CALCULATION_CHART_IDS <= set(ids)
|
||
assert "MOON" in ids
|
||
assert not re.search(r"(?m)^##### .+\n\n+##### ", section)
|
||
assert not re.search(r"(?m)^##### .*Bhava", section)
|
||
assert "##### Navamsha" not in section and "##### p7 · Navamsha" not in section
|
||
|
||
|
||
def _heading_table(markdown: str, heading: str) -> list[list[str]]:
|
||
"""Like _table_after, but the lord table is a #### heading, not ###."""
|
||
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_birth_position_table_starts_with_the_ascendant(editions) -> None:
|
||
zh, en, packet = editions
|
||
asc = packet["core_chart"]["ascendant"]
|
||
longitude = asc.get("lon") if isinstance(asc.get("lon"), (int, float)) else asc.get("degree_raw")
|
||
fields = _nakshatra_fields(float(longitude))
|
||
lord_zh = {
|
||
"Sun": "太阳", "Moon": "月亮", "Mars": "火星", "Mercury": "水星",
|
||
"Jupiter": "木星", "Venus": "金星", "Saturn": "土星",
|
||
"Rahu": "北交点", "Ketu": "南交点",
|
||
}
|
||
zh_rows = _heading_table(zh, "行星原始位置")
|
||
assert zh_rows[1][0] == "上升"
|
||
assert fields["nakshatra"] in zh_rows[1][6]
|
||
assert zh_rows[1][7] == str(fields["nakshatra_pada"])
|
||
assert zh_rows[1][5] == "1"
|
||
lords = _heading_table(zh, "行星主星与状态")
|
||
assert lords[1][0] == "上升"
|
||
assert lords[1][5] == "不适用"
|
||
assert lords[1][2] in {fields["nakshatra_lord"], lord_zh.get(fields["nakshatra_lord"])}
|
||
en_rows = _heading_table(en, "Planetary Raw Positions")
|
||
assert en_rows[1][0] in {"Ascendant", "Lagna", "上升"}
|
||
assert fields["nakshatra"] in en_rows[1][6]
|
||
assert en_rows[1][7] == str(fields["nakshatra_pada"])
|
||
|
||
|
||
def test_zh_hygiene_rejects_glued_english(editions) -> None:
|
||
from scripts.pl9_full_data_export import _pl9_markdown_hygiene_receipt, _zh_body_residue
|
||
injected = _pl9_markdown_hygiene_receipt("木星Jupiterexalted在巨蟹座\n", language="zh")
|
||
assert injected["status"] == "fail"
|
||
assert any(str(item).startswith("glued_latin:") for item in injected["violations"])
|
||
residue = _zh_body_residue(editions[0])
|
||
assert residue == {"glued_latin": 0, "function_words": 0}
|
||
|
||
|
||
def test_zh_hygiene_is_clean_on_night_and_a_public_chart() -> None:
|
||
from scripts.pl9_full_data_export import _zh_body_residue
|
||
# Public birth used by tests/smoke_test_runner.py. Not a private user.
|
||
public = {
|
||
**CASES["day"],
|
||
"name": "Albert Einstein",
|
||
"year": 1879,
|
||
"month": 3,
|
||
"day": 14,
|
||
"hour": 11,
|
||
"minute": 30,
|
||
"lat": 48.4,
|
||
"lon": 9.98,
|
||
"tz": 0.89,
|
||
"age": 76,
|
||
}
|
||
for case in (CASES["night"], public):
|
||
packet = _packet(case)
|
||
markdown = _pl9_export_markdown_for_edition(copy.deepcopy(packet), "full_data")
|
||
assert _zh_body_residue(markdown) == {"glued_latin": 0, "function_words": 0}
|
||
|
||
|
||
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)
|
||
# 原值:### Natal and Divisional Charts (North Indian) 下一行是英文 caveat
|
||
# 新值:### Natal and Divisional North Indian Charts;英文句或保留的中文 caveat 都算在
|
||
# 原因:完整数据版英文清理用上游标题。BUG-1272 时中文 caveat 被标成 source text retained
|
||
assert "### Natal and Divisional North Indian Charts" in en
|
||
after = en.split("### Natal and Divisional North Indian Charts", 1)[1][:800]
|
||
assert "Divisional charts are sensitive to birth time" in after or "分盘对出生时间敏感" in after
|