#!/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", })