Files
Jyotisha/tests/test_qizheng_chart_engine.py
T
jesse-ux a38a941522
Independent Staging Quality Gate / validate (push) Failing after 8m1s
Independent Staging Quality Gate / publish (push) Skipped
fix(qizheng): pass school options into the vendored chart and stabilize gates
BUG-710: call getSevenGovernorsChart with ketuMode/siderealMode so
calculation.ketu_mode is the engine school, not the request echo.
BUG-711: stop forbidding /ephemeris in the sidebar contract.
BUG-712: compare ephemeris event longitudes at 6 decimals and fail
closed when the golden is missing.
2026-09-15 17:54:20 +08:00

171 lines
7.1 KiB
Python

"""Adapter tests for the vendored seven-governors engine."""
from __future__ import annotations
import json
import shutil
import subprocess
from pathlib import Path
from types import SimpleNamespace
import pytest
from scripts import qizheng_chart_engine as engine
from scripts.qizheng_chart_engine import calculate_qizheng_chart
ROOT = Path(__file__).resolve().parents[1]
GOLDEN = ROOT / "tests" / "golden" / "qizheng_stem_branch_19900409.json"
SAMPLE = {
"year": 1990,
"month": 4,
"day": 9,
"hour": 13,
"minute": 24,
"lat": 31.19,
"lon": 121.44,
"tz": 8,
}
def _require_node() -> None:
if shutil.which("node") is None:
pytest.skip("node runtime is not available")
def test_golden_fixture_is_real_engine_output() -> None:
chart = json.loads(GOLDEN.read_text(encoding="utf-8"))
assert set(chart["bodies"]) == set(engine.REQUIRED_BODIES)
assert len(chart["palaces"]) == 12
assert chart["ascendant"]["palace"] == "午宮"
assert chart["ketuMode"] == "apogee"
sun = chart["bodies"]["sun"]
assert sun["mansion"] == "奎"
assert round(sun["siderealLon"], 2) == 177.94
assert round(sun["mansionDegree"], 2) == 7.44
assert chart["dignities"]["sun"] == "陷"
assert chart["starSpirits"] == []
assert len(chart["aspects"]) == 27
def test_live_chart_matches_golden_and_product_contract() -> None:
_require_node()
golden = json.loads(GOLDEN.read_text(encoding="utf-8"))
result = calculate_qizheng_chart(SAMPLE)
assert result["coordinate_system"] == "qizheng_mansion_degrees_from_jiao"
assert set(result["bodies"]) == set(engine.REQUIRED_BODIES)
assert len(result["palaces"]) == 12
assert result["ascendant"]["palace"] == golden["ascendant"]["palace"]
assert result["calculation"]["ketu_mode"] == "apogee"
assert result["calculation"]["sidereal_mode"] == {"type": "modern"}
assert result["dignities"]["status"] == "unclosed"
assert result["dignities"]["may_enter_conclusions"] is False
assert result["dignities"]["runtime_promotable_count"] == 0
assert "神煞" not in result["boundary"]
assert "未闭合" in result["boundary"] or "未闭合" in result["dignities"]["status"] or result["dignities"]["status"] == "unclosed"
assert round(result["bodies"]["sun"]["siderealLon"], 2) == round(golden["bodies"]["sun"]["siderealLon"], 2)
raw = json.dumps(result["raw_engine_output"], ensure_ascii=False)
assert "vendor" not in raw
assert "stem-branch" not in raw or result["raw_engine_output"].get("ketuMode") == "apogee"
assert "\\" not in raw or "[redacted-path]" in raw
def test_descending_node_ketu_mode_reaches_the_engine() -> None:
_require_node()
apogee = calculate_qizheng_chart(SAMPLE)
descending = calculate_qizheng_chart({**SAMPLE, "ketu_mode": "descending-node"})
assert descending["calculation"]["ketu_mode"] == "descending-node"
assert descending["calculation"]["engine_ketu_mode"] == "descending-node"
assert descending["calculation"]["ketu_mode"] == descending["calculation"]["engine_ketu_mode"]
assert descending["bodies"]["ketu"]["mansion"] != apogee["bodies"]["ketu"]["mansion"] or (
round(descending["bodies"]["ketu"]["mansionDegree"], 2)
!= round(apogee["bodies"]["ketu"]["mansionDegree"], 2)
)
def test_calculation_ketu_mode_never_disagrees_with_engine_on_success() -> None:
_require_node()
for mode in ("apogee", "descending-node"):
result = calculate_qizheng_chart({**SAMPLE, "ketu_mode": mode})
assert result["calculation"]["ketu_mode"] == result["calculation"]["engine_ketu_mode"] == mode
def test_classical_sidereal_mode_reaches_the_engine() -> None:
_require_node()
modern = calculate_qizheng_chart(SAMPLE)
classical = calculate_qizheng_chart({**SAMPLE, "sidereal_mode": "classical"})
assert classical["calculation"]["sidereal_mode"] == {"type": "classical", "epoch": "kaiyuan"}
assert classical["calculation"]["engine_sidereal_mode"] == {"type": "classical", "epoch": "kaiyuan"}
assert classical["calculation"]["sidereal_mode"] == classical["calculation"]["engine_sidereal_mode"]
assert round(classical["bodies"]["sun"]["siderealLon"], 2) != round(modern["bodies"]["sun"]["siderealLon"], 2)
def test_missing_lat_is_structured_input_error() -> None:
with pytest.raises(engine.QizhengChartError, match="lat is required") as caught:
calculate_qizheng_chart({k: v for k, v in SAMPLE.items() if k != "lat"})
assert caught.value.error_code == "ERR_QIZHENG_INPUT"
def test_node_missing_error(monkeypatch) -> None:
monkeypatch.setattr(engine.shutil, "which", lambda name: None)
with pytest.raises(engine.QizhengChartError, match="unavailable") as caught:
calculate_qizheng_chart(SAMPLE)
assert caught.value.error_code == "ERR_QIZHENG_NODE_MISSING"
def test_cli_missing_error(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setattr(engine, "VENDORED_CLI", tmp_path / "cli.cjs")
monkeypatch.setattr(engine.shutil, "which", lambda name: "node")
with pytest.raises(engine.QizhengChartError, match="unavailable") as caught:
calculate_qizheng_chart(SAMPLE)
assert caught.value.error_code == "ERR_QIZHENG_CLI_MISSING"
def test_timeout_error(monkeypatch) -> None:
monkeypatch.setattr(engine.shutil, "which", lambda name: "node")
def _boom(*args, **kwargs): # noqa: ANN001
raise subprocess.TimeoutExpired(cmd="node", timeout=20)
monkeypatch.setattr(engine.subprocess, "run", _boom)
with pytest.raises(engine.QizhengChartError, match="超时") as caught:
calculate_qizheng_chart(SAMPLE)
assert caught.value.error_code == "ERR_QIZHENG_TIMEOUT"
def test_nonzero_exit_does_not_leak_stderr(monkeypatch) -> None:
monkeypatch.setattr(engine.shutil, "which", lambda name: "node")
monkeypatch.setattr(
engine.subprocess,
"run",
lambda *args, **kwargs: SimpleNamespace(
returncode=1, stdout="", stderr="C:\\\\secret\\\\stem-branch\\\\cli.cjs exploded"
),
)
with pytest.raises(engine.QizhengChartError, match="exited with an error") as caught:
calculate_qizheng_chart(SAMPLE)
assert caught.value.error_code == "ERR_QIZHENG_ENGINE_EXIT"
assert "secret" not in str(caught.value)
assert "cli.cjs" not in str(caught.value)
def test_non_json_stdout_error(monkeypatch) -> None:
monkeypatch.setattr(engine.shutil, "which", lambda name: "node")
monkeypatch.setattr(
engine.subprocess,
"run",
lambda *args, **kwargs: SimpleNamespace(returncode=0, stdout="not-json", stderr=""),
)
with pytest.raises(engine.QizhengChartError, match="JSON") as caught:
calculate_qizheng_chart(SAMPLE)
assert caught.value.error_code == "ERR_QIZHENG_BAD_JSON"
def test_source_never_invokes_forbidden_cli_flags() -> None:
source = Path(engine.__file__).read_text(encoding="utf-8")
helper = Path(engine.EVAL_HELPER).read_text(encoding="utf-8")
assert "getSevenGovernorsChart" in helper
command_block = source.split("command = [", 1)[1].split("]", 1)[0]
for flag in engine.FORBIDDEN_CLI_FLAGS:
assert flag not in command_block
assert flag not in helper