研究章仍在六章之后、口径替换之前。独立年度方法参考仍在口径替换之后,来源说明不会被删掉。两条打回测试的替身补上额外参数,不改报告计算,不改原有断言。指纹记录改称 BUG-1430,因为 BUG-1429 已被研究章占用。 跑过两条打回测试、六章与指纹相关测试,以及 200 张英文扫描,退出码都是 0。 Co-Authored-By: Grok (agent loop)
386 lines
14 KiB
Python
386 lines
14 KiB
Python
"""Issue #93: PyJHora annual method reference stays in one child process."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from scripts.diagnose_pyjhora_adapter import build_product_report
|
|
from scripts.pl9_full_data_export import read_full_data_domain, render_full_data_markdown
|
|
from scripts.rectification_three_engine_packet import _pyjhora_d1
|
|
from scripts.report_render.annual_method_reference import (
|
|
CHAPTER_EN,
|
|
CHAPTER_ZH,
|
|
build_pyjhora_annual_method_reference,
|
|
normalize_worker_payload,
|
|
mask_report_fingerprint_lines,
|
|
render_annual_method_reference,
|
|
reset_reference_cache,
|
|
set_reference_runner,
|
|
strip_annual_method_chapter,
|
|
)
|
|
from tests.report_english_fictional import fictional_case
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def setup_function() -> None:
|
|
reset_reference_cache()
|
|
set_reference_runner(None)
|
|
|
|
|
|
def test_result_hash_includes_the_annual_method_chapter() -> None:
|
|
# 原值: 两个标记的 result_hash 相同(本章被摘出指纹)。
|
|
# 新值: 两个标记的 result_hash 不同,且本章仍留在资料包里。
|
|
# 原因: 交付包里有这一章时,重算指纹必须对得上(BUG-1430;合并 staging 后 1429 已是研究章日出记录)。
|
|
from scripts.calculation_profile_contract import bind_result_to_profile
|
|
|
|
profile = {"input_hash": "ab" * 32}
|
|
|
|
def packet(marker: str) -> dict:
|
|
return {
|
|
"value": 1,
|
|
"worksheets": {
|
|
"timing_and_predictive_systems": {
|
|
"kept": "same",
|
|
"pyjhora_annual_method_reference": {"marker": marker},
|
|
}
|
|
},
|
|
}
|
|
|
|
installed = bind_result_to_profile(packet("installed"), profile)
|
|
blocked = bind_result_to_profile(packet("blocked"), profile)
|
|
assert installed["result_hash"] != blocked["result_hash"]
|
|
assert installed["worksheets"]["timing_and_predictive_systems"]["pyjhora_annual_method_reference"]["marker"] == "installed"
|
|
assert installed["worksheets"]["timing_and_predictive_systems"]["kept"] == "same"
|
|
|
|
|
|
def test_fingerprint_lines_match_after_masking_for_the_chapter_compare() -> None:
|
|
left = "\n".join([
|
|
"## 报告身份与来源",
|
|
"- 报告 ID: `full-report://one/aaaaaaaaaaaaaaaaaaaaaaaa`",
|
|
"- 计算结果摘要: `0123456789abcdef...`",
|
|
"## 前面的章",
|
|
"same",
|
|
f"## {CHAPTER_ZH}",
|
|
"only-in-left",
|
|
]) + "\n"
|
|
right = "\n".join([
|
|
"## 报告身份与来源",
|
|
"- 报告 ID: `full-report://two/bbbbbbbbbbbbbbbbbbbbbbbb`",
|
|
"- 计算结果摘要: `fedcba9876543210...`",
|
|
"## 前面的章",
|
|
"same",
|
|
f"## {CHAPTER_ZH}",
|
|
"only-in-right",
|
|
]) + "\n"
|
|
assert mask_report_fingerprint_lines(strip_annual_method_chapter(left)) == mask_report_fingerprint_lines(
|
|
strip_annual_method_chapter(right)
|
|
)
|
|
assert "aaaaaaaaaaaaaaaaaaaaaaaa" not in mask_report_fingerprint_lines(left)
|
|
assert "0123456789abcdef" not in mask_report_fingerprint_lines(left)
|
|
|
|
|
|
def test_reference_edition_does_not_request_the_annual_method_worker(monkeypatch) -> None:
|
|
from types import SimpleNamespace
|
|
|
|
from scripts.professional_report_reference import (
|
|
FULL_DATA_EDITION,
|
|
_assemble_professional_report_reference,
|
|
)
|
|
|
|
seen: list[bool] = []
|
|
|
|
def build(_reading, _args, _packs, include_annual_method_reference=True):
|
|
seen.append(include_annual_method_reference)
|
|
return {"report_version": "v", "worksheets": {}}
|
|
|
|
engine = SimpleNamespace(
|
|
build_professional_report_reference_packet=build,
|
|
render_pl9_markdown=lambda _packet: "standard",
|
|
)
|
|
handler = SimpleNamespace(_compute_full_reading_for_thematic=lambda _birth: {})
|
|
birth = fictional_case(0)
|
|
reference = _assemble_professional_report_reference(
|
|
handler, birth, "markdown", "reference", [], [], False, engine,
|
|
)
|
|
assert reference["markdown"] == "standard"
|
|
assert seen == [False]
|
|
|
|
monkeypatch.setattr(
|
|
"scripts.professional_report_reference.render_full_data_markdown",
|
|
lambda _packet: "full-data",
|
|
)
|
|
full = _assemble_professional_report_reference(
|
|
handler, birth, "markdown", FULL_DATA_EDITION, [], [], False, engine,
|
|
)
|
|
assert full["markdown"] == "full-data"
|
|
assert seen == [False, True]
|
|
|
|
|
|
def test_product_report_does_not_probe(monkeypatch) -> None:
|
|
def _boom(*_args, **_kwargs):
|
|
raise AssertionError("product diagnostics must not probe jhora")
|
|
|
|
monkeypatch.setattr("scripts.diagnose_pyjhora_adapter.importlib.util.find_spec", _boom)
|
|
report = build_product_report()
|
|
assert report["status"] == "missing_dependency"
|
|
assert report["dependency_available"] is False
|
|
assert report["missing_dependency"] == "jhora"
|
|
|
|
|
|
def test_pyjhora_d1_stays_on_the_missing_install_row() -> None:
|
|
source = (ROOT / "scripts" / "rectification_three_engine_packet.py").read_text(encoding="utf-8")
|
|
assert "import_module" not in source
|
|
assert "import jhora" not in source
|
|
with pytest.raises(ModuleNotFoundError):
|
|
_pyjhora_d1({"year": 1990})
|
|
|
|
|
|
def test_deploy_does_not_set_year_lord_pyjhora_reference() -> None:
|
|
hits = []
|
|
for path in (ROOT / "deploy").rglob("*"):
|
|
if not path.is_file():
|
|
continue
|
|
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
if "JYOTISH_YEAR_LORD_PYJHORA_REFERENCE" in text:
|
|
hits.append(path.relative_to(ROOT).as_posix())
|
|
assert hits == []
|
|
|
|
|
|
def test_timeout_and_nonzero_exit_leave_every_table_unavailable() -> None:
|
|
def _timeout(_payload, timeout):
|
|
raise subprocess.TimeoutExpired(cmd="worker", timeout=timeout)
|
|
|
|
set_reference_runner(_timeout)
|
|
reference = build_pyjhora_annual_method_reference(_args(0), {"birth_info": {}})
|
|
assert _all_unavailable(reference)
|
|
|
|
def _exit(_payload, _timeout):
|
|
return {"ok": False, "reason": "exit_3"}
|
|
|
|
set_reference_runner(_exit)
|
|
reference = build_pyjhora_annual_method_reference(_args(0), {"birth_info": {}})
|
|
assert _all_unavailable(reference)
|
|
|
|
|
|
def test_normalize_keeps_nine_yogas_and_two_mudda_tables() -> None:
|
|
raw = {
|
|
"ok": True,
|
|
"ayanamsa_ok": True,
|
|
"ayanamsa_readback": "RAMAN",
|
|
"node_header": "mean",
|
|
"years": [{
|
|
"requested_year": 2026,
|
|
"anchor": {"date": [2026, 6, 15], "time": "16:35:04"},
|
|
"mudda_birth": [[[0, 1], [2026, 1, 2, 3.5], 30]],
|
|
"mudda_annual": [[[2, "L"], [2026, 6, 15, 1], 10]],
|
|
"patyayini": [
|
|
[[3, 4], [2026, 6, 15, 0], 0.4],
|
|
[[5, 6], [2026, 11, 1, 0], 0.6],
|
|
],
|
|
"tajika": {
|
|
"yogas": {name: [] for name in (
|
|
"ithasala", "eesarpha", "manahoo", "kamboola", "nakta", "yamaya",
|
|
)},
|
|
"failures": {"radda": "NameError"},
|
|
},
|
|
}],
|
|
}
|
|
raw["years"][0]["tajika"]["yogas"]["gairi_kamboola"] = None
|
|
raw["years"][0]["tajika"]["yogas"]["khallasara"] = None
|
|
second = json.loads(json.dumps(raw["years"][0]))
|
|
third = json.loads(json.dumps(raw["years"][0]))
|
|
second["requested_year"] = 2027
|
|
third["requested_year"] = 2028
|
|
raw["years"].extend([second, third])
|
|
reference = normalize_worker_payload(raw, 2026)
|
|
tables = reference["years"][0]["tables"]
|
|
assert tables["mudda_birth"]["rows"][0]["main_lord"] == "Sun"
|
|
assert tables["mudda_annual"]["rows"][0]["sub_lord"] == "Lagna"
|
|
assert tables["patyayini"]["rows"][0]["end"] == "2026-11-01 00:00:00"
|
|
assert tables["patyayini"]["rows"][1]["end"] is None
|
|
states = {row["yoga"]: row["state"] for row in tables["tajika"]["rows"]}
|
|
assert len(states) == 9
|
|
assert states["Radda"] == "unavailable"
|
|
assert states["Gairi-Kamboola"] == "unknown"
|
|
assert states["Ithasala"] == "empty"
|
|
|
|
|
|
def test_chapter_is_last_and_domain_view_omits_the_key() -> None:
|
|
packet = {
|
|
"report_language": "zh",
|
|
"worksheets": {"timing_and_predictive_systems": {"pyjhora_annual_method_reference": {
|
|
"years": [_shell(2026), _shell(2027), _shell(2028)],
|
|
}}},
|
|
}
|
|
chinese = render_full_data_markdown(packet)
|
|
english_packet = dict(packet)
|
|
english_packet["report_language"] = "en"
|
|
english = render_full_data_markdown(english_packet)
|
|
assert chinese.rstrip().splitlines()[-1] != ""
|
|
assert _h2(chinese)[-1] == CHAPTER_ZH
|
|
assert _h2(english)[-1] == CHAPTER_EN
|
|
assert chinese.count("来源:PyJHora") == 3
|
|
assert english.count("Source: PyJHora") == 3
|
|
assert "未获得结果" in chinese and "Result unavailable" in english
|
|
assert strip_annual_method_chapter(chinese) == strip_annual_method_chapter(strip_annual_method_chapter(chinese))
|
|
assert CHAPTER_ZH not in strip_annual_method_chapter(chinese)
|
|
domain = read_full_data_domain(packet, "career")
|
|
timing = domain["worksheets"]["timing_and_predictive_systems"]
|
|
assert "pyjhora_annual_method_reference" not in timing
|
|
|
|
|
|
def test_three_fictional_charts_match_a_second_annual_chart_call() -> None:
|
|
"""Real PyJHora, three fictional charts. Birth fields stay out of the assertion text."""
|
|
script = r"""
|
|
import json, sys
|
|
sys.path[:0] = [sys.argv[1], sys.argv[1] + "\\scripts"]
|
|
from tests.report_english_fictional import fictional_case
|
|
from scripts.report_render.annual_method_reference import _invoke_worker, _payload_from
|
|
class Args:
|
|
pass
|
|
out = []
|
|
for index in (0, 1, 9):
|
|
birth = fictional_case(index)
|
|
args = Args()
|
|
for key, value in birth.items():
|
|
setattr(args, key, value)
|
|
payload = _payload_from(args, {})
|
|
if index == 0:
|
|
payload["prove_reuse"] = True
|
|
payload["include_upstream_diff"] = True
|
|
raw = _invoke_worker(payload, 120)
|
|
years = raw.get("years") or []
|
|
diff = (years[0].get("upstream_diff") if years else None) or {}
|
|
out.append({
|
|
"i": index,
|
|
"ok": bool(raw.get("ok")),
|
|
"ayanamsa_ok": bool(raw.get("ayanamsa_ok")),
|
|
"readback": raw.get("ayanamsa_readback"),
|
|
"node": raw.get("node_header"),
|
|
"reuse": [bool(year.get("reuse_matches")) for year in years],
|
|
"documented_anchor": (diff.get("documented_anchor") or {}).get("date"),
|
|
"upstream_anchor": (diff.get("upstream_anchor") or {}).get("date"),
|
|
"documented_mudda": diff.get("documented_mudda_start"),
|
|
"upstream_mudda": diff.get("upstream_mudda_start"),
|
|
"yoga_keys": sorted(((years[0].get("tajika") or {}).get("yogas") or {}).keys()) if years else [],
|
|
"radda": ((years[0].get("tajika") or {}).get("failures") or {}).get("radda"),
|
|
"jhora_in_parent": any(name == "jhora" or name.startswith("jhora.") for name in sys.modules),
|
|
})
|
|
print(json.dumps(out, ensure_ascii=False))
|
|
"""
|
|
completed = subprocess.run(
|
|
[sys.executable, "-c", script, str(ROOT)],
|
|
cwd=ROOT,
|
|
text=True,
|
|
capture_output=True,
|
|
timeout=400,
|
|
check=False,
|
|
)
|
|
assert completed.returncode == 0, completed.stderr[-1000:]
|
|
rows = json.loads(completed.stdout)
|
|
assert [row["i"] for row in rows] == [0, 1, 9]
|
|
for row in rows:
|
|
assert row["ok"] and row["ayanamsa_ok"], row
|
|
assert row["readback"]
|
|
if row["i"] == 0:
|
|
assert row["reuse"] == [True, True, True]
|
|
assert row["documented_anchor"] and row["upstream_anchor"]
|
|
assert row["jhora_in_parent"] is False
|
|
assert "radda" not in row["yoga_keys"]
|
|
assert row["radda"]
|
|
|
|
|
|
def test_parent_moon_and_ayanamsa_stay_put() -> None:
|
|
script = r"""
|
|
import json, sys
|
|
sys.path[:0] = [sys.argv[1], sys.argv[1] + "\\scripts"]
|
|
import swisseph as swe
|
|
from tests.report_english_fictional import fictional_case
|
|
from scripts.report_render.annual_method_reference import build_pyjhora_annual_method_reference
|
|
birth = fictional_case(0)
|
|
class Args:
|
|
pass
|
|
args = Args()
|
|
for key, value in birth.items():
|
|
setattr(args, key, value)
|
|
jd = swe.julday(2000, 1, 1, 0)
|
|
before_ay = swe.get_ayanamsa_ut(jd)
|
|
before_moon = swe.calc_ut(jd, swe.MOON)[0][0]
|
|
reference = build_pyjhora_annual_method_reference(args, {})
|
|
after_ay = swe.get_ayanamsa_ut(jd)
|
|
after_moon = swe.calc_ut(jd, swe.MOON)[0][0]
|
|
print(json.dumps({
|
|
"same_ayanamsa": before_ay == after_ay,
|
|
"same_moon": before_moon == after_moon,
|
|
"jhora": any(name == "jhora" or name.startswith("jhora.") for name in sys.modules),
|
|
"years": len(reference.get("years") or []),
|
|
"tables": sorted((reference.get("years") or [{}])[0].get("tables", {})),
|
|
}))
|
|
"""
|
|
completed = subprocess.run(
|
|
[sys.executable, "-c", script, str(ROOT)],
|
|
cwd=ROOT,
|
|
text=True,
|
|
capture_output=True,
|
|
timeout=90,
|
|
check=False,
|
|
)
|
|
assert completed.returncode == 0, completed.stderr[-1000:]
|
|
payload = json.loads(completed.stdout.strip().splitlines()[-1])
|
|
assert payload["same_ayanamsa"] and payload["same_moon"]
|
|
assert payload["jhora"] is False
|
|
assert payload["years"] == 3
|
|
assert payload["tables"] == ["mudda_annual", "mudda_birth", "patyayini", "tajika"]
|
|
|
|
|
|
def _args(index: int):
|
|
birth = fictional_case(index)
|
|
|
|
class Args:
|
|
pass
|
|
|
|
args = Args()
|
|
for key, value in birth.items():
|
|
setattr(args, key, value)
|
|
return args
|
|
|
|
|
|
def _shell(year: int) -> dict:
|
|
return {
|
|
"requested_year": year,
|
|
"ayanamsa": "RAMAN",
|
|
"ayanamsa_ok": False,
|
|
"node_header": "mean",
|
|
"patyayini_anchor": None,
|
|
"tajika_anchor": None,
|
|
"tables": {name: {"ok": False, "rows": []} for name in ("mudda_birth", "mudda_annual", "patyayini", "tajika")},
|
|
}
|
|
|
|
|
|
def _all_unavailable(reference: dict) -> bool:
|
|
years = reference.get("years") or []
|
|
return len(years) == 3 and all(
|
|
all(not year["tables"][name]["ok"] for name in year["tables"])
|
|
for year in years
|
|
)
|
|
|
|
|
|
def _h2(markdown: str) -> list[str]:
|
|
return [line[3:].strip() for line in markdown.splitlines() if line.startswith("## ")]
|
|
|
|
|
|
def test_rendered_english_chapter_has_no_han() -> None:
|
|
text = render_annual_method_reference({
|
|
"worksheets": {"timing_and_predictive_systems": {"pyjhora_annual_method_reference": {
|
|
"years": [_shell(2026)],
|
|
}}},
|
|
}, "en")
|
|
assert "Source: PyJHora" in text
|
|
assert not any("\u4e00" <= char <= "\u9fff" for char in text)
|