Files
Jyotisha/tests/test_flexible_birth_time_engine.py
T
2026-09-04 02:11:57 +08:00

236 lines
8.3 KiB
Python

#!/usr/bin/env python3
"""Focused engine/API behavior for report birth-time sensitivity windows."""
from __future__ import annotations
from datetime import datetime
from hashlib import sha256
import json
import os
import sys
from types import SimpleNamespace
import pytest
SCRIPTS = os.path.join(os.path.dirname(__file__), "..", "scripts")
if SCRIPTS not in sys.path:
sys.path.insert(0, SCRIPTS)
import flexible_birth_time_profile as profile_module # noqa: E402
import jyotish_api_server as api # noqa: E402
import jyotish_engine as engine # noqa: E402
def _args(**overrides) -> SimpleNamespace:
values = {
"year": 2004,
"month": 5,
"day": 6,
"hour": 10,
"minute": 7,
"second": 0,
"lat": 12.34,
"lon": 56.78,
"tz": 5.5,
"ayanamsa": "raman",
"node_mode": "mean",
"birth_time_accuracy": "provisional",
"candidate_range": None,
"declared_window_start": None,
"declared_window_end": None,
"representative_time": None,
"uncertainty_before_minutes": None,
"uncertainty_after_minutes": None,
"event_replay_file": None,
}
values.update(overrides)
return SimpleNamespace(**values)
def _fictional_recast(candidate: datetime, **_kwargs) -> dict:
sensitive_sign = "Capricorn" if candidate.minute < 7 else "Aquarius"
return {
"ascendant": {"sign": "Aries"},
"varga_lagna": {
"D9": {"sign": "Taurus"},
"D10": {"sign": sensitive_sign},
},
"arudha": {"A7": {"sign": "Gemini"}, "A10": {"sign": sensitive_sign}, "UL": {"sign": "Cancer"}},
"kp_cusps": {"house_10": {"sub_lord": "Saturn" if candidate.minute < 7 else "Mercury"}},
}
def _digest(value: dict) -> str:
payload = json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
return sha256(payload.encode("utf-8")).hexdigest()
def test_clock_parser_requires_exact_hh_mm() -> None:
center = datetime(2004, 5, 6, 10, 7)
assert engine._clock_on_birth_date(center, "09:05") == datetime(2004, 5, 6, 9, 5)
assert engine._clock_on_birth_date(center, None) is None
for invalid in ("9:05", "09:05:00", "09:05 trailing", "24:00", ""):
with pytest.raises(ValueError, match="birth_time_clock_must_be_hh_mm"):
engine._clock_on_birth_date(center, invalid)
def test_explicit_reverse_or_incomplete_window_is_rejected_instead_of_falling_back() -> None:
center = datetime(2004, 5, 6, 10, 7)
with pytest.raises(ValueError, match="must_not_cross_midnight"):
engine._birth_time_candidate_window(
_args(declared_window_start="23:58", declared_window_end="00:02"),
center,
"provisional",
)
with pytest.raises(ValueError, match="requires_start_and_end"):
engine._birth_time_candidate_window(
_args(declared_window_start="10:00"),
center,
"provisional",
)
with pytest.raises(ValueError, match="birth_time_clock_must_be_hh_mm"):
engine._birth_time_candidate_window(
_args(declared_window_start="10:00 extra", declared_window_end="10:10"),
center,
"provisional",
)
fallback = engine._birth_time_candidate_window(
_args(uncertainty_before_minutes=1, uncertainty_after_minutes=1),
center,
"provisional",
)
assert tuple(value.strftime("%H:%M") for value in fallback) == ("10:06", "10:07", "10:08")
@pytest.mark.parametrize(
"args",
[
_args(birth_time_accuracy="confirmed"),
_args(declared_window_start="10:07", declared_window_end="10:07", representative_time="10:07"),
_args(birth_time_accuracy="approximate", uncertainty_before_minutes=0, uncertainty_after_minutes=0),
],
)
def test_confirmed_or_actual_single_minute_skips_candidate_recast(monkeypatch, args: SimpleNamespace) -> None:
monkeypatch.setattr(
profile_module,
"_recast_candidate_layers",
lambda *_args, **_kwargs: pytest.fail("single-minute input must not recast candidate charts"),
)
assert engine._build_birth_time_sensitivity(args) == {
"schema": "jyotish.report_birth_time_sensitivity.v1",
"status": "not_applicable",
"accuracy": "confirmed",
}
@pytest.mark.parametrize(
("range_status", "end_time", "candidate_count"),
[("accepted", "10:01", 2), ("candidate", "10:14", 15)],
)
def test_multi_minute_accepted_or_candidate_windows_remain_provisional(
monkeypatch, range_status: str, end_time: str, candidate_count: int,
) -> None:
monkeypatch.setattr(profile_module, "_recast_candidate_layers", _fictional_recast)
args = _args(
candidate_range={
"status": range_status,
"start_time": "10:00",
"end_time": end_time,
"representative_time": "10:07",
},
)
first = engine._build_birth_time_sensitivity(args)
second = engine._build_birth_time_sensitivity(args)
assert first["status"] == "candidate_window_only"
assert first["accuracy"] == "provisional"
assert first["window"]["candidate_count"] == candidate_count
assert _digest(first) == _digest(second)
for minute_values in first["sensitive_evidence"]["layers"].values():
assert len({json.dumps(value, sort_keys=True) for value in minute_values.values()}) > 1
for theme in first["theme_sensitivity"].values():
for layer in theme["sensitive_layers"]:
values = first["sensitive_evidence"]["layers"][layer]
assert len({json.dumps(value, sort_keys=True) for value in values.values()}) > 1
def test_confirmed_report_packet_does_not_gain_sensitivity_key() -> None:
packet = {"worksheets": {}, "raw_full_reading": {"modules": {}}}
result = engine._attach_report_governance_contracts(packet, _args(birth_time_accuracy="confirmed"))
assert "birth_time_sensitivity" not in result
@pytest.mark.parametrize("accuracy", ["confirmed", "provisional", "approximate"])
def test_birth_time_accuracy_accepts_only_exact_supported_values(accuracy: str) -> None:
assert engine._birth_time_accuracy(_args(birth_time_accuracy=accuracy)) == accuracy
def test_birth_time_accuracy_defaults_to_confirmed_when_field_is_absent() -> None:
assert engine._birth_time_accuracy(SimpleNamespace()) == "confirmed"
@pytest.mark.parametrize("invalid", [None, "", "bogus", " PROVISIONAL ", "PROVISIONAL", 1])
def test_birth_time_accuracy_rejects_invalid_or_formatted_values(invalid: object) -> None:
with pytest.raises(
ValueError,
match="birth_time_accuracy_must_be_confirmed_provisional_or_approximate",
):
engine._birth_time_accuracy(_args(birth_time_accuracy=invalid))
def test_api_turns_invalid_birth_time_accuracy_into_bad_request(monkeypatch) -> None:
class FakeEngine:
@staticmethod
def cmd_full_reading(args):
engine._birth_time_accuracy(args)
return {"modules": {}}
monkeypatch.setattr(api, "_load_local_module", lambda _name: FakeEngine)
handler = api.JyotishAPIHandler.__new__(api.JyotishAPIHandler)
with pytest.raises(
api.BadRequest,
match="birth_time_accuracy_must_be_confirmed_provisional_or_approximate",
):
handler._compute_full_reading_for_thematic({
"year": 2004,
"month": 5,
"day": 6,
"hour": 10,
"minute": 7,
"lat": 12.34,
"lon": 56.78,
"tz": 5.5,
"birth_time_accuracy": " PROVISIONAL ",
})
def test_api_turns_invalid_report_window_into_bad_request(monkeypatch) -> None:
class FakeEngine:
@staticmethod
def cmd_full_reading(_args):
raise ValueError("birth_time_window_must_not_cross_midnight")
monkeypatch.setattr(api, "_load_local_module", lambda _name: FakeEngine)
handler = api.JyotishAPIHandler.__new__(api.JyotishAPIHandler)
with pytest.raises(api.BadRequest, match="birth_time_window_must_not_cross_midnight"):
handler._compute_full_reading_for_thematic({
"year": 2004,
"month": 5,
"day": 6,
"hour": 10,
"minute": 7,
"lat": 12.34,
"lon": 56.78,
"tz": 5.5,
"birth_time_accuracy": "provisional",
"declared_window_start": "23:58",
"declared_window_end": "00:02",
})