Files
Jyotisha/tests/test_full_data_parity_chapters.py
T
jesse-uxandGrok (agent loop) d45e65a9ec 完整数据版补上研究口径的推运、次小运和细分盘(#92)。
研究章在渲染时计算,不写入资料包。四种星座大运次小运和 Kalachakra 第三层不改原来的主运、子运。D81、D108、D144 只留在研究章。

自测:test_research_timing、星座大运、域读取、卫生预算、真恒星、jhora 扫描、API 增长、六章标题、英文版。三张虚构盘中英出稿都低于 180 秒。PyJHora 已导入但未做逐层对照。

Co-Authored-By: Grok (agent loop)
2026-10-23 18:20:44 +08:00

522 lines
24 KiB
Python

"""Six parity chapters for issue #91 section A.
C-class sections stay absent. Labels are bilingual. Narayana's two methods
are separate headings. Fictional charts only.
"""
from __future__ import annotations
import copy
import re
import pytest
from scripts.pl9_full_data_export import render_full_data_markdown
from scripts.pl9_reader_english import HAN
from scripts.professional_report_reference import _english_edition
from scripts.report_render.appendix_language import chinese_prose_gates
from scripts.report_render.parity_chapters import (
CHAPTERS,
FORBIDDEN_TITLES,
LABELS,
SECTIONS,
_chapter_sections,
lab,
render_parity_chapters,
)
from tests.report_english_fictional import _load_packet
_SNAKE = re.compile(r"[A-Za-z][A-Za-z0-9]*_[A-Za-z0-9_]+")
_KAAL_ZH = re.compile(r"\d+ 颗中 \d+ 颗")
_KAAL_EN = re.compile(r"\d+ of \d+")
def _chapter_text(markdown: str, language: str) -> str:
marker = "\n## 大运附表\n" if language == "zh" else "\n## Dasha Appendix\n"
assert marker in markdown
return markdown.split(marker, 1)[1]
def _parity_chapter_bodies(tail: str, language: str) -> dict[str, str]:
"""Body of each of the six parity chapters, stopping before later research headings."""
titles = [chapter_zh if language == "zh" else chapter_en for chapter_en, chapter_zh in CHAPTERS]
bodies: dict[str, str] = {}
remaining = tail
for index, title in enumerate(titles):
next_title = titles[index + 1] if index + 1 < len(titles) else None
if next_title is not None:
marker = f"\n## {next_title}\n"
head, _sep, rest = remaining.partition(marker)
bodies[title] = head
remaining = rest
continue
stop = remaining.find("\n## ")
bodies[title] = remaining if stop < 0 else remaining[:stop]
return bodies
def _section_body(markdown: str, heading: str) -> str:
marker = f"### {heading}\n"
start = markdown.index(marker) + len(marker)
rest = markdown[start:]
ends = [pos for pos in (rest.find("\n### "), rest.find("\n## "), rest.find("\n# ")) if pos >= 0]
return rest[: min(ends)] if ends else rest
def _heading_lines(markdown: str, level: int) -> list[str]:
prefix = "#" * level + " "
return [line[len(prefix):].strip() for line in markdown.splitlines() if line.startswith(prefix)]
def test_chapter_titles_follow_the_task_groups() -> None:
groups = _chapter_sections()
assert len(groups) == len(CHAPTERS) == 6
assert [title for group in groups for title in group] == [title for title, _zh in SECTIONS]
for (chapter_en, chapter_zh), titles in zip(CHAPTERS, groups):
assert chapter_en and chapter_zh
assert titles
def test_labels_cover_both_languages_and_a_missing_label_fails() -> None:
for key, pair in LABELS.items():
assert pair[0] and pair[1], key
assert lab(key, "en") == pair[0]
assert lab(key, "zh") == pair[1]
assert "_" not in pair[0] and "_" not in pair[1]
with pytest.raises(KeyError):
lab("not_a_real_column", "zh")
def test_empty_packet_chapters_are_headed_and_do_not_print_raw_keys() -> None:
for language in ("zh", "en"):
text = render_parity_chapters({}, language)
titles = SECTIONS
cursor = 0
for title_en, title_zh in titles:
heading = title_zh if language == "zh" else title_en
body = _section_body(text, heading)
assert body.strip(), heading
cursor_next = text.index(f"### {heading}\n", cursor)
assert cursor_next >= cursor
cursor = cursor_next
if "|---|" in body or "| --- |" in body:
lines = [line for line in body.splitlines() if line.startswith("|")]
assert len(lines) >= 3, heading
for title in FORBIDDEN_TITLES:
assert f"### {title}" not in text
assert f"## {title}" not in text
assert f"# {title}" not in text
assert _SNAKE.search(text) is None
assert _heading_lines(text, 1) == []
chapters = [chapter_zh if language == "zh" else chapter_en for chapter_en, chapter_zh in CHAPTERS]
assert _heading_lines(text, 2) == chapters
zh = render_parity_chapters({}, "zh")
en = render_parity_chapters({}, "en")
assert zh.index("#### 来源感知口径") < zh.index("#### 既有口径")
assert "来源感知口径" not in zh.split("#### 既有口径", 1)[1]
assert en.index("#### Source-aware method") < en.index("#### Legacy method")
assert "Source-aware method" not in en.split("#### Legacy method", 1)[1]
assert "印刷页 10" in zh
assert "printed pages 10-17" in en
for banned in ("source_locator", "profile_id", "niryaana_shoola_strength", "source_discrepancy", "sign_idx"):
assert banned not in zh
assert banned not in en
@pytest.fixture(scope="module")
def fictional_reports() -> dict[int, tuple[str, str]]:
reports = {}
for index in (0, 1, 9):
packet = _load_packet(index)
edition = _english_edition(packet, True)
chinese = render_full_data_markdown(packet)
english = edition.get("markdown_en") or ""
assert edition.get("english_unavailable") in (None, "")
assert english
reports[index] = (chinese, english)
return reports
def _between(markdown: str, heading: str, marks: tuple[str, ...]) -> str:
marker = f"{heading}\n"
start = markdown.index(marker) + len(marker)
rest = markdown[start:]
ends = [pos for pos in (rest.find(mark) for mark in marks) if pos >= 0]
return rest[: min(ends)] if ends else rest
def test_later_years_repeat_annual_sections(fictional_reports: dict[int, tuple[str, str]]) -> None:
"""K2: 2027 and 2028 carry the same annual sections as 2026."""
sections = (
("年运整宫框架", "Annual Return House Framework"),
("年运返照计算约定", "Annual Return Calculation Contract"),
("Tajika 相位与瑜伽候选", "Tajika Aspect and Yoga Candidates"),
("Tripataki 轮盘明细", "Tripataki Chakra Detail"),
("年度 Tajika 力量", "Annual Tajika Strength"),
("年度特殊点公式轨迹", "Annual Saham Formula Trace"),
)
for index, (chinese, english) in fictional_reports.items():
for title_zh, title_en in sections:
zh_body = _section_body(_chapter_text(chinese, "zh"), title_zh)
en_body = _section_body(_chapter_text(english, "en"), title_en)
for year in ("2026", "2027", "2028"):
assert f"#### {year}" in zh_body, f"{index} {title_zh} {year}"
assert f"#### {year}" in en_body, f"{index} {title_en} {year}"
zh_year = zh_body.split(f"#### {year}", 1)[1].split("\n#### ", 1)[0]
en_year = en_body.split(f"#### {year}", 1)[1].split("\n#### ", 1)[0]
assert zh_year.strip() and en_year.strip()
if title_zh != "Tajika 相位与瑜伽候选":
assert re.search(r"\d", zh_year), f"{index} {title_zh} {year}"
assert re.search(r"\d", en_year), f"{index} {title_en} {year}"
assert zh_body.count("年主依据") == 3 or title_zh != "年运返照计算约定"
assert en_body.count("Year-lord basis") == 3 or title_en != "Annual Return Calculation Contract"
lifetime = _between(chinese, "### 生命周期年度返照图盘数据", ("\n### ", "\n## "))
assert len([line for line in lifetime.splitlines() if line.startswith("| ") and "---" not in line]) >= 73
en_life = _between(english, "### Lifetime annual return charts", ("\n### ", "\n## "))
assert len([line for line in en_life.splitlines() if line.startswith("| ") and "---" not in line]) >= 73
zh_planets = _between(chinese, "## 行星、星宿与宫位解释", ("\n## ",))
en_planets = _between(english, "## Planets, Nakshatras and Houses", ("\n## ",))
zh_houses = _section_body(chinese, "Bhavesh 宫主逐宫解释")
en_houses = _section_body(english, "House-lord interpretation")
assert len(en_planets) >= 8970, (index, len(en_planets))
assert len(en_houses) >= 8970, (index, len(en_houses))
assert len(zh_planets) >= 3320, (index, len(zh_planets))
assert len(zh_houses) >= 3320, (index, len(zh_houses))
def test_three_fictional_charts_render_the_same_chapters(fictional_reports: dict[int, tuple[str, str]]) -> None:
for index, (chinese, english) in fictional_reports.items():
assert chinese_prose_gates(chinese) == {"english_signs": 0, "han_function_glue": 0}
assert HAN.findall(english) == []
assert "[translation missing:" not in english
zh = _chapter_text(chinese, "zh")
en = _chapter_text(english, "en")
zh_at = 0
en_at = 0
for title_en, title_zh in SECTIONS:
zh_at = zh.index(f"### {title_zh}\n", zh_at)
en_at = en.index(f"### {title_en}\n", en_at)
for body, heading in ((_section_body(zh, title_zh), title_zh), (_section_body(en, title_en), title_en)):
assert body.strip(), f"{index} {heading}"
for chapter_en, chapter_zh in CHAPTERS:
assert _heading_lines(chinese, 2).count(chapter_zh) == 1
assert _heading_lines(english, 2).count(chapter_en) == 1
# 原值:大运附表之后的全文不得出现 D81、D108、D144、独立年度方法参考
# 新值:六章附表各自的正文仍不得出现这些标题;全文里这三个分盘只出现在研究口径章节
# 原因:#92 把研究型分盘从普通分盘表挪到附表之后的研究口径章节,只允许出现在那一章
zh_before, _, zh_research = chinese.partition("## 研究型分盘")
en_before, _, en_research = english.partition("## Research divisional charts")
for token in ("D81", "D108", "D144"):
assert token not in zh_before and token not in en_before
assert token in zh_research and token in en_research
bodies_zh = _parity_chapter_bodies(zh, "zh")
bodies_en = _parity_chapter_bodies(en, "en")
for chapter_en, chapter_zh in CHAPTERS:
for title in FORBIDDEN_TITLES:
assert title not in bodies_zh[chapter_zh]
assert title not in bodies_en[chapter_en]
assert "研究口径:依赖所选方法,未经外部软件核对。" in chinese
assert "Research basis: method-dependent; not checked against external software." in english
assert _SNAKE.search(zh) is None
assert _SNAKE.search(en) is None
assert "印刷页 10" in zh and "printed pages 10-17" in en
assert "#### 来源感知口径" in zh and "#### 既有口径" in zh
assert "#### Source-aware method" in en and "#### Legacy method" in en
zh_yogi = _section_body(zh, "Jaimini Yogi 与 Ava Yogi")
en_yogi = _section_body(en, "Jaimini Yogi and Ava Yogi")
assert "Yogi" in zh_yogi and "Ava Yogi" in zh_yogi
assert "Yogi" in en_yogi and "Ava Yogi" in en_yogi
assert "未判定" not in zh_yogi
assert "Not determined" not in en_yogi
assert "再小运" in _section_body(zh, "Chara 大运时长")
assert "Pratyantardasha" in _section_body(en, "Chara Dasha Period Durations")
assert "marriage_windows" not in _section_body(zh, "Bhrigu 近似推进序列")
assert "venus_encounter" not in _section_body(en, "Bhrigu Approximate Progression")
assert "Raja Yoga" not in zh
assert "blocked" not in zh.lower()
_UNDETERMINED = {"zh": "未判定", "en": "Not determined"}
_GAP_LINE = {
"zh": "公开报告资料包未带此模块",
"en": "This module is not in the public report packet",
}
# These four modules are absent from the public worksheet. The reason is why
# the section may stay undetermined. Anything else must show a real cell.
_PACKET_GAPS = {
"Navamsha AD Duration": "navamsha_dasha is computed on the reading but is not copied into the public worksheet",
"Pancha Pakshi": "pancha_pakshi is not copied into the public worksheet",
"Rashi Tulya Navamsa": "rashi_tulya_navamsa is not copied into the public worksheet",
"Current Dasha Convergence": "dasa_convergence per-domain rows are not copied into the public worksheet",
}
# Only this section may be a single sentence: "no boundary within N days".
_PROSE_ONLY = {"Dasha Boundary Scan"}
def _table_blocks(body: str) -> list[list[list[str]]]:
blocks = []
current: list[str] = []
for line in body.splitlines():
if line.startswith("|"):
current.append(line)
continue
if current:
blocks.append(current)
current = []
if current:
blocks.append(current)
parsed = []
for block in blocks:
rows = []
for index, line in enumerate(block):
cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
if cells and all(set(cell) <= set("-: ") and cell for cell in cells):
continue
if index == 0:
continue
rows.append(cells)
parsed.append(rows)
return parsed
_NOT_VALUES = {"未判定", "Not determined", "—", "-", ""}
# A whole cell that looks like a packet key (`summary`, `p5`, `house_7`).
_RAW_KEY_CELL = re.compile(r"^[a-z][a-z0-9]*(_[a-z0-9]+)*$")
def _section_has_real_cell(body: str, undetermined: str) -> bool:
"""A table must carry a value outside its first (label) column.
The first column holds row labels, so it never proves that the packet was
read. A one-column table therefore never passes. A body with no table at all
may be a plain sentence (for example "no boundary within 90 days").
"""
blocks = _table_blocks(body)
if blocks:
return any(
cell not in _NOT_VALUES and cell != undetermined
for rows in blocks
for row in rows
for cell in row[1:]
)
prose = [line.strip() for line in body.splitlines() if line.strip() and not line.startswith("#")]
return any(line != undetermined for line in prose)
def _all_cells(markdown: str) -> list[str]:
cells = []
for line in markdown.splitlines():
if not line.startswith("|"):
continue
parts = [cell.strip() for cell in line.strip().strip("|").split("|")]
if parts and all(set(cell) <= set("-: ") and cell for cell in parts):
continue
cells.extend(parts)
return cells
def test_every_section_has_a_real_cell_except_the_four_public_gaps(fictional_reports: dict[int, tuple[str, str]]) -> None:
titles = dict(SECTIONS)
for reason in _PACKET_GAPS.values():
assert reason
for index, (chinese, english) in fictional_reports.items():
for language, markdown in (("zh", chinese), ("en", english)):
chapter = _chapter_text(markdown, language)
for title_en, title_zh in SECTIONS:
heading = title_zh if language == "zh" else title_en
body = _section_body(chapter, heading)
if title_en in _PACKET_GAPS:
first = next(line.strip() for line in body.splitlines() if line.strip())
assert first == _GAP_LINE[language], f"{index} {heading}"
continue
assert _section_has_real_cell(body, _UNDETERMINED[language]), f"{index} {heading}"
if title_en not in _PROSE_ONLY:
# A fixed sentence is not packet data; every other section shows a table.
assert _table_blocks(body), f"{index} {heading}"
def test_every_section_has_a_real_cell_outside_the_label_column_unit() -> None:
one_column = "| 角色 |\n|---|\n| 未判定 |\n| summary |\n"
assert not _section_has_real_cell(one_column, "未判定")
labels_only = "| 项目 | 内容 |\n|---|---|\n| 状态 | 未判定 |\n| 宫位 | — |\n"
assert not _section_has_real_cell(labels_only, "未判定")
real = "| 项目 | 内容 |\n|---|---|\n| 宫位 | 7 |\n"
assert _section_has_real_cell(real, "未判定")
assert _section_has_real_cell("参考日起 90 天内没有大运或小运交界。", "未判定")
assert not _section_has_real_cell("未判定", "未判定")
def test_no_table_cell_is_a_raw_packet_key(fictional_reports: dict[int, tuple[str, str]]) -> None:
for index, (chinese, english) in fictional_reports.items():
for language, markdown in (("zh", chinese), ("en", english)):
chapter = _chapter_text(markdown, language)
keys = sorted({cell for cell in _all_cells(chapter) if _RAW_KEY_CELL.fullmatch(cell)})
assert keys == [], f"{index} {language} {keys[:10]}"
def test_six_chapters_are_reader_chapters_and_sections_sit_below_them(monkeypatch: pytest.MonkeyPatch) -> None:
"""The reader splits on `## ` and ignores `# `. The six chapters must be `##`."""
import scripts.pl9_full_data_export as export
zh_chapters = [chapter_zh for _chapter_en, chapter_zh in CHAPTERS]
en_chapters = [chapter_en for chapter_en, _chapter_zh in CHAPTERS]
packet = _load_packet(0)
full_zh = render_full_data_markdown(copy.deepcopy(packet))
full_en = _english_edition(copy.deepcopy(packet), True).get("markdown_en") or ""
# 原值:拿掉六章后,二级标题比完整报告少且只少这六章
# 新值:基准同时拿掉六章和研究口径章节;完整报告在六章之后还有研究章节
# 原因:#92 的研究章节也是后加的二级标题,不把它算进「原文」会把六章的位置顶乱
monkeypatch.setattr(export, "_append_parity_chapters", lambda markdown, _packet, _language: markdown)
monkeypatch.setattr(export, "_append_research_chapters", lambda markdown, _packet, _language: markdown)
base_zh = render_full_data_markdown(copy.deepcopy(packet))
base_en = _english_edition(copy.deepcopy(packet), True).get("markdown_en") or ""
assert base_en
research_zh = "补充推运计算数据"
research_en = "Additional timing calculations"
for full, base, chapters, research_title in (
(full_zh, base_zh, zh_chapters, research_zh),
(full_en, base_en, en_chapters, research_en),
):
h1 = _heading_lines(full, 1)
assert len(h1) == 1, h1
assert full.startswith("# " + h1[0])
assert _heading_lines(base, 1) == h1
full_h2 = _heading_lines(full, 2)
base_h2 = _heading_lines(base, 2)
assert full_h2[: len(base_h2)] == base_h2
assert full_h2[len(base_h2): len(base_h2) + len(chapters)] == chapters
research_headings = full_h2[len(base_h2) + len(chapters):]
assert research_headings[0] == research_title
tail = full.split("\n## " + chapters[0] + "\n", 1)[1]
assert _heading_lines(tail, 3)[: len(_chapter_sections()[0])] == [
(dict(SECTIONS)[title] if chapters is zh_chapters else title) for title in _chapter_sections()[0]
]
assert len(_heading_lines(tail, 3)) == len(SECTIONS)
def test_parity_chapters_go_through_local_conventions() -> None:
from scripts.pl9_full_data_export import _apply_local_report_conventions
packet = {
"report_language": "zh",
"worksheets": {
"strengths_and_scores": {
"vimsopaka": {
"Sun": {"planet": "Sun", "total_score": 12.5, "category": "必然"},
}
}
},
}
chinese = render_full_data_markdown(packet)
zh_body = _section_body(chinese, "Vimsopaka 分盘贡献")
zh_replaced = _apply_local_report_conventions("必然", language="zh")
assert zh_replaced == "可能"
assert zh_replaced in zh_body
assert "必然" not in zh_body
english_packet = copy.deepcopy(packet)
english_packet["report_language"] = "en"
english_packet["worksheets"]["strengths_and_scores"]["vimsopaka"]["Sun"]["category"] = "Divorce risk"
# A bare lower-case word is now treated as an internal key and is not
# printed, so the probe is a reader-style phrase (fix2 F1).
english = render_full_data_markdown(english_packet)
en_body = _section_body(english, "Vimsopaka Contribution")
en_replaced = _apply_local_report_conventions("Divorce risk", language="en")
assert en_replaced == "relationship strain risk"
assert en_replaced in en_body
assert "divorce" not in en_body.lower()
def _combination_cells(markdown: str) -> list[str]:
cells: list[str] = []
column = None
for line in markdown.splitlines():
if not line.startswith("|"):
column = None
continue
parts = [cell.strip() for cell in line.strip().strip("|").split("|")]
if column is None:
column = parts.index("组合条件") if "组合条件" in parts else -1
continue
if column < 0 or all(set(cell) <= set("-: ") and cell for cell in parts):
continue
if column < len(parts):
cells.append(parts[column])
return cells
def test_chinese_yoga_combinations_are_not_blanked(fictional_reports: dict[int, tuple[str, str]]) -> None:
"""BUG-1418: staging printed Chinese text in these cells; none may fall back to 未判定."""
for index, (chinese, english) in fictional_reports.items():
cells = _combination_cells(chinese)
assert cells, index
assert [cell for cell in cells if cell == "未判定"] == [], index
assert "组合:未判定" not in chinese
assert HAN.findall(english) == []
def test_empty_tajika_candidates_are_one_sentence() -> None:
"""Acceptance note: an empty candidate list is a sentence, not a row of 未判定."""
packet = {
"worksheets": {
"timing_and_predictive_systems": {
"annual_tajika_pack": {"tajika_yogas": {"data": {"candidate_yogas": []}}},
}
}
}
chinese = render_parity_chapters(packet, "zh")
english = render_parity_chapters(packet, "en")
zh_body = _section_body(chinese, "Tajika 相位与瑜伽候选")
en_body = _section_body(english, "Tajika Aspect and Yoga Candidates")
assert "本年没有候选" in zh_body
assert "No candidates this year" in en_body
assert "未判定" not in zh_body
assert "Not determined" not in en_body
assert "|" not in zh_body
def test_empty_goel_occupants_say_none() -> None:
"""Acceptance note: an empty sign-strength occupant cell is 无 / None."""
packet = {
"worksheets": {
"advanced_systems": {
"jaimini": {
"goel_full_strength": {
"status": "computed",
"atmakaraka": "Sun",
"node_occupancy_policy": "include_nodes_text",
"planets": {
"Sun": {"sign_idx": 0, "degree_in_sign": 1.0, "dignity": "own", "total": 10},
},
"rashi_strengths": [
{"sign": "Aries", "lord": "Mars", "permanent": 1, "occupants": [], "total": 10},
],
}
}
}
}
}
chinese = render_parity_chapters(packet, "zh")
english = render_parity_chapters(packet, "en")
zh_body = _section_body(chinese, "Goel Jaimini 强度与特殊点")
en_body = _section_body(english, "Goel Jaimini Strength and Special Points")
assert "| 白羊座 | 火星 | 1 | 无 | 10 |" in zh_body or "| 白羊 | 火星 | 1 | 无 | 10 |" in zh_body
assert "None" in en_body
assert "未判定" not in zh_body.split("落入行星", 1)[-1].split("\n", 3)[2]
def test_chart_nine_partial_kaal_and_friendship(fictional_reports: dict[int, tuple[str, str]]) -> None:
chinese, english = fictional_reports[9]
assert _KAAL_ZH.search(chinese)
assert _KAAL_EN.search(english)
zh_friends = _section_body(_chapter_text(chinese, "zh"), "行星中性与复合友敌关系")
en_friends = _section_body(_chapter_text(english, "en"), "Neutral and Compound Relationships")
zh_rows = [line for line in zh_friends.splitlines() if line.startswith("| 太阳") or line.startswith("| 月亮") or line.startswith("| 水星")]
assert len([line for line in zh_friends.splitlines() if line.startswith("| ") and "---" not in line]) >= 10
assert zh_rows
assert "极友" in zh_friends
assert "Great friend" in en_friends
assert "没有大运或小运交界" in chinese or "距参考日天数" in _section_body(_chapter_text(chinese, "zh"), "大运与小运交界检查")