方法体未改。定向测试 383 通过;1 条校正回归在基线上同样失败,断言未改。快速门和全量 pytest 未跑。 Co-Authored-By: Grok (agent loop)
305 lines
11 KiB
Python
305 lines
11 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_keeps_unrectified_sensitivity_surface() -> None:
|
|
packet = {"worksheets": {}, "raw_full_reading": {"modules": {}}}
|
|
|
|
result = engine._attach_report_governance_contracts(packet, _args(birth_time_accuracy="confirmed"))
|
|
|
|
assert result["birth_time_sensitivity"]["status"] == "not_rectified"
|
|
assert result["birth_time_sensitivity"]["reason"] == "no_candidate_window"
|
|
|
|
|
|
@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.JyotishComputeService()
|
|
|
|
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.JyotishComputeService()
|
|
|
|
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",
|
|
})
|
|
|
|
|
|
def test_candidate_minutes_covers_every_minute_in_a_27_minute_window() -> None:
|
|
start = datetime(2004, 5, 6, 10, 0)
|
|
representative = datetime(2004, 5, 6, 10, 7)
|
|
end = datetime(2004, 5, 6, 10, 26)
|
|
times = engine._candidate_minutes(start, representative, end)
|
|
assert times == [f"10:{index:02d}" for index in range(27)]
|
|
|
|
|
|
def test_candidate_minutes_samples_thirty_one_including_bounds_for_a_60_minute_window() -> None:
|
|
start = datetime(2004, 5, 6, 10, 0)
|
|
representative = datetime(2004, 5, 6, 10, 7)
|
|
end = datetime(2004, 5, 6, 10, 59)
|
|
times = engine._candidate_minutes(start, representative, end)
|
|
assert len(times) == 31
|
|
assert times[0] == "10:00"
|
|
assert "10:07" in times
|
|
assert times[-1] == "10:59"
|
|
assert len(set(times)) == 31
|
|
|
|
|
|
def test_sensitivity_27_minute_window_recasts_every_minute(monkeypatch) -> None:
|
|
monkeypatch.setattr(profile_module, "_recast_candidate_layers", _fictional_recast)
|
|
args = _args(
|
|
candidate_range={
|
|
"start_time": "10:00",
|
|
"end_time": "10:26",
|
|
"representative_time": "10:07",
|
|
},
|
|
)
|
|
result = engine._build_birth_time_sensitivity(args)
|
|
assert result["window"]["candidate_count"] == 27
|
|
assert result["window"]["sampled"] is False
|
|
|
|
|
|
def test_sensitivity_60_minute_window_samples_thirty_one_and_marks_sampled(monkeypatch) -> None:
|
|
monkeypatch.setattr(profile_module, "_recast_candidate_layers", _fictional_recast)
|
|
args = _args(
|
|
candidate_range={
|
|
"start_time": "10:00",
|
|
"end_time": "10:59",
|
|
"representative_time": "10:07",
|
|
},
|
|
)
|
|
result = engine._build_birth_time_sensitivity(args)
|
|
assert result["window"]["candidate_count"] == 31
|
|
assert result["window"]["sampled"] is True
|
|
times = list(result["window"]["candidate_times"])
|
|
assert times[0] == "10:00"
|
|
assert "10:07" in times
|
|
assert times[-1] == "10:59"
|
|
|
|
|
|
def test_approximate_wide_window_uses_three_coarse_samples(monkeypatch) -> None:
|
|
monkeypatch.setattr(profile_module, "_recast_candidate_layers", _fictional_recast)
|
|
args = _args(
|
|
birth_time_accuracy="approximate",
|
|
candidate_range={
|
|
"start_time": "10:00",
|
|
"end_time": "10:59",
|
|
"representative_time": "10:07",
|
|
},
|
|
)
|
|
result = engine._build_birth_time_sensitivity(args)
|
|
assert result["window"]["candidate_count"] == 3
|
|
assert result["window"]["sampled"] is True
|
|
assert set(result["window"]["candidate_times"]) == {"10:00", "10:07", "10:59"}
|