"""Report completion for the full-data edition (TASK-report-full-data-fix-20261008). Template wording, annual field projection, the opening focus, and the two mistranslations. Chart numbers come from the packet. These tests do not calculate a new dasha or import PyJHora. """ from __future__ import annotations import copy import json import re from scripts.pl9_full_data_export import _sanitize_pl9_ai_density_markdown, render_full_data_markdown from scripts.pl9_interpretation_packs import ( _current_lords, attach_full_data_interpretation, missing_field_count, opening_focus_block, section_coverage, ) from scripts.report_focus_block import FOCUS_FENCE_RE, parse_focus_fence from scripts.pl9_reader_english import HAN FORBIDDEN_ZH = ( "再婚", "几段关系", "早夭", "一定会", "必然", "Neecha Bhanga Raja Yoga", "落陷取消王", "疾病诊断", "疾病", ) FORBIDDEN_EN = ( "divorce", "miscarriage", "infertility", "guarantee", "guaranteed", ) HALF_TRANSLATED = re.compile(r"[A-Za-z]+\s*[\u4e00-\u9fff]+s") def _planet(sign: str, house: int, nakshatra: str, status: str = "Own Sign") -> dict: return {"sign": sign, "house": house, "status": status, "nakshatra": nakshatra} def _packet(language: str = "zh") -> dict: planets = { "Sun": _planet("Aries", 10, "Ashwini", "入旺(Exalted)"), "Moon": _planet("Taurus", 2, "Rohini"), "Mars": _planet("Capricorn", 7, "Exalted"), "Mercury": _planet("Pisces", 6, "Debilitated"), "Jupiter": _planet("Cancer", 1, "Exalted"), "Venus": _planet("Libra", 4, "Own Sign"), "Saturn": _planet("Aquarius", 8, "Own Sign"), "Rahu": _planet("Gemini", 12, "Ardra", ""), "Ketu": _planet("Sagittarius", 6, "Mula", ""), } lords = ["Mars", "Venus", "Mercury", "Moon", "Sun", "Mercury", "Venus", "Mars", "Jupiter", "Saturn", "Saturn", "Jupiter"] houses = {f"house_{index}": {"lord": lord, "cusp_sign": "Aries"} for index, lord in enumerate(lords, start=1)} timeline = [] for lord in ("Sun", "Moon", "Mars"): current = lord == "Moon" timeline.append({ "lord": lord, "start": "2000-01-01", "end": "2006-01-01", "is_current": current, "antardasha_timeline": [{ "lord": "Mars", "start": "2001-01-01", "end": "2002-01-01", "is_current": current, "pratyantar_dasha_timeline": [{ "lord": "Mercury", "start": "2001-02-01", "end": "2001-04-01", "is_current": current, }], }], }) return { "report_language": language, "birth_info": {"name": "虚构甲" if language == "zh" else "Fictional"}, "worksheets": { "d1_rasi_bhava": {"planets": planets, "houses": houses, "ascendant": {"sign": "Cancer"}}, "strengths_and_scores": { "functional_benefic_malefic": { "functional_benefics": ["Jupiter", "Mars"], "functional_malefics": ["Saturn"], "functional_neutrals": ["Moon"], }, "shadbala": {"planets": {"Sun": {"total": 1.25}}}, }, "timing_and_predictive_systems": { "dasha": {"timeline": timeline, "current_dasha": timeline[1]}, "annual_tajika_pack": { "solar_return": {"status": "partial_verified", "data": {"dt_local": "2026-04-07T09:00:00"}}, "mudda_dasha": { "status": "partial_verified", "periods": [{"lord": "Sun", "start": "2026-04-07", "end": "2026-05-07", "months": 1}], }, "patyayini_dasha": {"status": "blocked", "reason": "not_in_engine"}, "sahams": {"status": "partial_verified", "data": [{"name": "Punya", "sign": "Aries", "degree_in_sign": 1.2}]}, "tajika_yogas": { "status": "partial_verified", "data": {"yogas": [{"name": "Ithasala", "present": True, "planets": ["Sun", "Moon"]}]}, }, "monthly_windows": [{ "index": 1, "lord": "Sun", "duration_months": 1, "start": "2026-04-07", "end": "2026-05-07", }], }, }, "advanced_systems": { "yoga": { "detected_yogas": [{ "name": "Gajakesari Yoga", "name_cn": "象鹿瑜伽", "category": "raja", "effects": ["名声可见"], "effects_en": ["visible reputation"], "planets": ["Moon", "Jupiter"], "combination": "Moon and Jupiter", }, { "name": "Neecha Bhanga Raja Yoga", "name_cn": "落陷取消王瑜伽", "category": "neecha_bhanga", "effects": ["传统条件成立"], "effects_en": ["the cancellation conditions hold"], "planets": ["Mercury"], }], }, "yogas_doshas": {"mangal_dosha": {"has_dosha": False, "mars_house": 7, "severity": "none"}}, }, }, } def test_t5_repairs_bhava_bala_and_planets_suffix() -> None: raw = "\n".join([ "# 印度占星星盘报告", "", "### p31-p33 Shadbala / Bhava Bala 摘要", "", "### p32 Bhava Bala 十二宫力量", "", "### p125 Natal / KP Ruling Planets", "", "### Upagraha and Sub-Planets Points", "", "### Planetary Friendship Matrix", "", ]) text = _sanitize_pl9_ai_density_markdown(raw) assert "Bhava 年龄状态" not in text assert "六维力量 / Bhava Bala" in text assert "Bhava Bala 十二宫力量" in text assert "行星s" not in text # Ruling planets are not significators (Claude acceptance 2026-10-08): was 本命 / KP 征象星. assert "本命 / KP 主宰星" in text assert HALF_TRANSLATED.search(text) is None assert "年龄状态" not in text.split("Graha Avastha", 1)[-1] or "Baladi" not in raw def test_templates_cover_required_sections_and_forbidden_wording() -> None: packet = _packet("zh") attach_full_data_interpretation(packet) focus = opening_focus_block(packet) # 原值:focus.startswith("重点:"),并且正文含 MD / AD / PD。 # 新值:开篇是「## 重点」加一个 jyotish-focus 围栏,不再写那一行行话。 # 原因:产品选择把开篇做成摘要卡,围栏交给阅读页画,不在正文里堆 MD/AD/PD。 assert focus.startswith("## 重点") assert "重点:当前主运" not in focus assert "MD " not in focus and "AD " not in focus and "PD " not in focus payload = parse_focus_fence(focus) assert payload is not None assert [row["house"] for row in payload["domains"]] == [10, 2, 7] text = render_full_data_markdown(packet) for heading in ("大运解释", "行星、星宿与宫位解释", "宫主逐宫解释", "Dosha 逐项事实与解释", "瑜伽解释"): assert heading in text assert "Patyayini 没有实现" in text assert "Mudda 月段窗口" in text assert "blocked" not in text.lower() for phrase in FORBIDDEN_ZH: assert phrase not in text assert "落陷取消(Neecha Bhanga" in text assert "Raja Yoga" not in text assert HALF_TRANSLATED.search(text) is None assert "行星s" not in text coverage = {row["section"]: row["status"] for row in section_coverage(packet)} assert coverage["sthira"] == "out_of_scope" assert "Sthira Dasha" not in text assert missing_field_count(packet) <= 10 def test_english_focus_and_new_sections_have_no_han() -> None: packet = _packet("en") text = render_full_data_markdown(packet) # 原值:正文含 "Focus: current Maha Dasha MD"。 # 新值:英文开篇是「## Focus」加 jyotish-focus 围栏,不再写那一行。 # 原因:与中文摘要卡同一结构;大运章里的 AD/PD 表头仍保留。 assert "## Focus" in text assert "```jyotish-focus" in text assert "Focus: current Maha Dasha MD" not in text assert "AD" in text and "PD" in text assert "Dasha Interpretation" in text assert "House-lord interpretation" in text assert "Yoga interpretation" in text assert "Patyayini is not implemented" in text assert "Monthly mudda windows" in text for phrase in FORBIDDEN_EN: assert phrase not in text.lower() assert "Neecha Bhanga Raja Yoga" not in text assert "not a Raja Yoga" in text leaked = HAN.findall(text) assert leaked == [], leaked[:12] def _focus_dates(packet: dict, level: str) -> tuple[str | None, str | None]: dasha = packet["worksheets"]["timing_and_predictive_systems"]["dasha"] current = dasha["current_dasha"] if level == "md": return current.get("start"), current.get("end") for row in current["antardasha_timeline"]: if not row.get("is_current"): continue if level == "ad": return row.get("start"), row.get("end") for child in row["pratyantar_dasha_timeline"]: if child.get("is_current"): return child.get("start"), child.get("end") return None, None def test_opening_focus_fence_matches_current_lords_and_skips_the_fixed_list() -> None: for language in ("zh", "en"): packet = _packet(language) lords = _current_lords(packet) text = render_full_data_markdown(packet) fences = FOCUS_FENCE_RE.findall(text.split("## 结构化资料附录", 1)[0].split("## Structured Data Appendix", 1)[0]) assert len(fences) == 1, language payload = json.loads(fences[0]) assert payload["version"] == 1 assert [row["level"] for row in payload["periods"]] == ["md", "ad", "pd"] assert [row["lord"] for row in payload["periods"]] == [lords["md"], lords["ad"], lords["pd"]] for row in payload["periods"]: start, end = _focus_dates(packet, row["level"]) assert row["start"] == start assert row["end"] == end assert [row["key"] for row in payload["domains"]] == ["career", "wealth", "relationship"] assert [row["house"] for row in payload["domains"]] == [10, 2, 7] opening = text.split("## 出生资料", 1)[0].split("## Birth Data", 1)[0] assert "重点:当前主运" not in opening assert "先核对出生资料、图盘" not in text if language == "zh": assert "MD " not in opening and "AD " not in opening and "PD " not in opening assert "功能吉凶为功能吉星" not in opening assert payload["caveat"] == "这段时间可能突出这些宫主所守护的主题,不是确定会发生的事。" else: assert "Focus: current Maha Dasha" not in opening def test_opening_focus_keeps_null_when_a_period_has_no_date() -> None: packet = _packet("zh") packet["worksheets"]["timing_and_predictive_systems"]["dasha"]["current_dasha"]["antardasha_timeline"][0]["end"] = "" payload = parse_focus_fence(render_full_data_markdown(packet)) assert payload is not None ad = next(row for row in payload["periods"] if row["level"] == "ad") assert ad["start"] == "2001-01-01" assert ad["end"] is None def test_yoga_effect_does_not_assert_lifespan() -> None: # 原值:作用列写「长寿;健康良好」/ Longevity。 # 新值:作用列改成体质与恢复力的参考句。名称「长寿格局」和 Ayur Yoga (Strong Longevity) 可以留下。 # 原因:格局名称是传统叫法,作用说明不再把寿命当断语。 packet = _packet("zh") packet["worksheets"]["advanced_systems"]["yoga"]["detected_yogas"].append({ "name": "Ayur Yoga (Strong Longevity)", "name_cn": "长寿格局", "category": "ayur", "strength": "strong", "effects": ["长寿", "健康良好"], "effects_en": ["Longevity", "Good health"], "combination": "traditional name only", }) chinese = render_full_data_markdown(packet) english_packet = copy.deepcopy(packet) english_packet["report_language"] = "en" english = render_full_data_markdown(english_packet) def without_names(text: str) -> str: text = text.replace("长寿格局", "") return re.sub(r"Ayur Yoga \(Strong Longevity\)", "", text, flags=re.IGNORECASE) for phrase in ("长寿", "寿命长", "短寿"): assert phrase not in without_names(chinese) assert "longevity" not in without_names(english).lower() assert "传统上与体质和恢复力有关,仅作参考" in chinese assert "constitution and recovery" in english.lower() def test_term_replacement_does_not_enter_identifiers() -> None: # 原值:functional_benefics / Kendras / conditions 变成「功能吉星s」「角宫s」「宫条件s」,字段名里的 sign 被换成星座。 # 新值:散文里的复数 s 吃进英文词;反引号和标识符保持原文。 # 原因:术语替换只能作用于自然语言。 raw = "\n".join([ "# 标题", "", "strict_functional_benefic_malefic_v2", "bphs_ch34_general_with_sign_exceptions_v2", "functional_benefics and Kendras and house conditions", "`functional_benefics`", ]) text = _sanitize_pl9_ai_density_markdown(raw) assert "strict_functional_benefic_malefic_v2" in text assert "bphs_ch34_general_with_sign_exceptions_v2" in text assert "`functional_benefics`" in text assert "功能吉星s" not in text assert "角宫s" not in text assert "宫条件s" not in text assert "功能吉星" in text assert re.search(r"[一-鿿]+s\b", text) is None rendered = render_full_data_markdown(_packet("zh")) assert re.search(r"[一-鿿]+s\b", rendered) is None mixed = re.search(r"[A-Za-z0-9_]*_[一-鿿]|[一-鿿]_[A-Za-z0-9_]", rendered) assert mixed is None, mixed.group(0) if mixed else "" def test_rahu_ketu_do_not_rule_an_unlisted_house() -> None: from scripts.pl9_interpretation_packs import _natal_en, _natal_zh, _sub_paragraph # 原值:空守护列表写成「守护第未列出宫」。 # 新值:罗睺、计都写「不守护宫位」/ rules no house。中文全文该病句计数为 0。 # 原因:这两颗星不守护宫位。其他「未列出」按句意保留。 empty = {"ownership": [], "house": 6, "nakshatra": "Mula"} filled = {"ownership": [1], "house": 6} row = {"start": "2001-01-01", "end": "2001-02-01"} assert "不守护宫位" in _sub_paragraph("Sun", "Rahu", row, empty, "zh", level="PD") assert "守护第未列出宫" not in _sub_paragraph("Sun", "Rahu", row, empty, "zh", level="PD") assert "不守护宫位" in _natal_zh("Ketu", filled, {}) assert "守护第1宫" in _natal_zh("Sun", filled, {}) assert "守护宫未单独列出" in _sub_paragraph("Sun", "Mars", row, empty, "zh", level="AD") assert "rules no house" in _natal_en("Rahu", filled, {}) text = render_full_data_markdown(_packet("zh")) assert text.count("守护第未列出宫") == 0 def test_chinese_render_stays_stable_when_english_uses_a_copy() -> None: packet = _packet("zh") before = render_full_data_markdown(copy.deepcopy(packet)) english = copy.deepcopy(packet) english["report_language"] = "en" render_full_data_markdown(english) after = render_full_data_markdown(copy.deepcopy(packet)) assert after == before def test_zh_headings_carry_no_untranslated_kp_or_row_words() -> None: # Claude acceptance 2026-10-08: after the term pass stopped rewriting inside # words, appendix headings such as "KP 行星 Significators" and # "Annual Tajika瑜伽 Rows" were left half translated. raw = "\n".join([ "# 印度占星星盘报告", "", "## KP Planet Significators", "", "## KP House Cusps and Significators", "", "### KP Significators / Planet Lords", "", "### KP Chart / Cusps", "", "## Annual Tajika Yoga Rows", "", "#### Dhana Yoga", "", ]) text = _sanitize_pl9_ai_density_markdown(raw) headings = re.findall(r"(?m)^#{2,6} (.*)$", text) pattern = re.compile(r"\b(Significators|Cusps|Rows|Lords|Ruling|Planets?)\b|[A-Za-z]{3,}[\u4e00-\u9fff]|[\u4e00-\u9fff]s\b") assert [h for h in headings if pattern.search(h)] == [] assert "KP 行星征象星" in headings and "KP 宫头与征象星" in headings and "年度 Tajika 瑜伽行" in headings