236 lines
8.3 KiB
Python
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",
|
|
})
|