Staging's backend gate (run 6338) failed three budgets: handler method count 218 > 217, JyotishAPIHandler.__new__ sites 6 > 5, and the scripts/ root .py count. The package and facts code now live in scripts/annual_service/ (package.py, report_facts.py, api.py with compute_annual_package / compute_annual_report_facts and AnnualServiceInputError). jyotish_api_server.py registers /api/annual mode=package, /api/annual/package and /api/annual/report_facts thinly through a module-level _annual_service helper, like /api/chart_points, with no handler methods: back to 217. Tests call the module functions and keep a thin-registration test; no cap or baseline changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y
454 lines
22 KiB
Python
454 lines
22 KiB
Python
"""Annual data package v1 (#152 ruling 2026-11-01; phase 1 of #153 / #154).
|
|
|
|
Birth data: the chart-insight golden profile (1990-06-15 12:00 Beijing,
|
|
Raman ayanamsa, mean node). Not a real person.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import functools
|
|
import json
|
|
import os
|
|
import sys
|
|
from datetime import UTC, datetime, timedelta
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
SCRIPTS = os.path.join(os.path.dirname(__file__), "..", "scripts")
|
|
if SCRIPTS not in sys.path:
|
|
sys.path.insert(0, SCRIPTS)
|
|
|
|
from annual_service import api as annual_api # noqa: E402
|
|
from annual_service import package as annual_package # noqa: E402
|
|
|
|
BadRequest = annual_api.AnnualServiceInputError
|
|
|
|
VOCAB = {"computed", "blocked", "not_applicable"}
|
|
YEAR_CHART_BLOCKS = {
|
|
"chart", "muntha", "year_lord", "annual_strength", "sahams", "tajika_yogas",
|
|
"tripataki", "mudda_dasha", "patyayini", "monthly_charts",
|
|
}
|
|
|
|
|
|
def _compute(body: dict) -> dict:
|
|
"""POST /api/annual mode=package and /api/annual/package both call this function."""
|
|
return annual_api.compute_annual_package(body)
|
|
|
|
|
|
def _body(**overrides):
|
|
body = {
|
|
"mode": "package",
|
|
"year": 1990, "month": 6, "day": 15, "hour": 12, "minute": 0, "second": 0,
|
|
"lat": 39.9042, "lon": 116.4074, "tz": 8, "timezone_id": "Asia/Shanghai",
|
|
"ayanamsa": "raman", "node_mode": "mean",
|
|
"report_range": {"kind": "calendar_year", "year": 2026},
|
|
"observation_date": "2026-11-01",
|
|
}
|
|
body.update(overrides)
|
|
return body
|
|
|
|
|
|
@functools.cache
|
|
def _cached(key: str) -> dict:
|
|
return _compute(json.loads(key))
|
|
|
|
|
|
def _package(**overrides) -> dict:
|
|
"""Built once per distinct request; callers must not mutate the result."""
|
|
return _cached(json.dumps(_body(**overrides), sort_keys=True))["package"]
|
|
|
|
|
|
def _utc(boundary: dict) -> datetime:
|
|
return datetime.strptime(boundary["utc"], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC)
|
|
|
|
|
|
def _assert_instant(boundary: dict) -> None:
|
|
assert set(boundary) == {"utc", "local", "utc_offset", "timezone_id", "jd_ut"}
|
|
local = datetime.fromisoformat(boundary["local"])
|
|
assert local.utcoffset() is not None
|
|
assert local.astimezone(UTC) == _utc(boundary)
|
|
|
|
|
|
# ── shape ──────────────────────────────────────────────────────────────────
|
|
|
|
def test_package_shape_and_version() -> None:
|
|
result = _cached(json.dumps(_body(), sort_keys=True))
|
|
assert result["success"] is True
|
|
assert result["mode"] == "package"
|
|
assert result["annual_package_version"] == 1
|
|
package = result["package"]
|
|
assert package["schema"] == "jyotish.annual_package.v1"
|
|
assert list(package) == [
|
|
"schema", "annual_package_version", "parameters", "report_range", "year_charts",
|
|
"natal_periods", "slow_transits", "independent_annual_views", "computation_boundaries",
|
|
"not_included", "block_index", "blocked_count",
|
|
]
|
|
json.dumps(package) # plain JSON, no datetime / circular references
|
|
for chart in package["year_charts"]:
|
|
assert chart["status"] in VOCAB
|
|
assert set(chart["blocks"]) == YEAR_CHART_BLOCKS
|
|
for name, item in chart["blocks"].items():
|
|
assert item["status"] in VOCAB, name
|
|
assert item["producer"], name
|
|
if item["status"] != "computed":
|
|
assert item["reason"], name
|
|
assert set(package["natal_periods"]) == {"vimshottari", "narayana"}
|
|
assert package["independent_annual_views"]["tithi_pravesha"]["status"] in VOCAB
|
|
assert package["block_index"], "every block is listed"
|
|
assert all(row["status"] in VOCAB for row in package["block_index"])
|
|
assert package["blocked_count"] == sum(1 for row in package["block_index"] if row["status"] == "blocked")
|
|
not_included = {row["item"] for row in package["not_included"]}
|
|
assert {"varsha_narayana", "candidate_windows", "real_case_calibration"} <= not_included
|
|
|
|
|
|
def test_no_interpretation_text_in_package() -> None:
|
|
assert '"interpretation"' not in json.dumps(_package(), ensure_ascii=False)
|
|
|
|
|
|
def test_core_tajika_blocks_are_computed_for_a_normal_chart() -> None:
|
|
chart = _package()["year_charts"][-1]
|
|
blocks = chart["blocks"]
|
|
for name in YEAR_CHART_BLOCKS:
|
|
assert blocks[name]["status"] == "computed", (name, blocks[name].get("reason"))
|
|
asc = blocks["chart"]["ascendant"]
|
|
assert blocks["chart"]["ascendant_lord"]["planet"] == annual_package.SIGN_LORDS[asc["sign"]]
|
|
muntha = blocks["muntha"]["data"]
|
|
assert muntha["muntha_lord"] == annual_package.SIGN_LORDS[muntha["muntha_sign"]]
|
|
assert muntha["muntha_lord_placement"]["planet"] == muntha["muntha_lord"]
|
|
assert 1 <= muntha["muntha_house_from_annual_ascendant"] <= 12
|
|
assert blocks["year_lord"]["data"]["year_lord"] in ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn")
|
|
assert "not natal Shadbala" in blocks["annual_strength"]["scope"]
|
|
assert set(blocks["annual_strength"]["data"]) == {"harsha_bala", "panchavargiya_bala"}
|
|
assert blocks["mudda_dasha"]["independent_of_year_lord"] is True
|
|
|
|
|
|
def test_every_boundary_is_an_exact_instant_with_timezone() -> None:
|
|
package = _package()
|
|
_assert_instant(package["report_range"]["start"])
|
|
_assert_instant(package["report_range"]["end"])
|
|
for chart in package["year_charts"]:
|
|
_assert_instant(chart["validity"]["start"])
|
|
_assert_instant(chart["validity"]["end"])
|
|
for row in chart["blocks"]["mudda_dasha"]["periods"] + chart["blocks"]["patyayini"]["periods"]:
|
|
_assert_instant(row["start"])
|
|
_assert_instant(row["end"])
|
|
for row in chart["blocks"]["monthly_charts"]["rows"]:
|
|
_assert_instant(row["start"])
|
|
_assert_instant(row["end"])
|
|
for row in package["natal_periods"]["vimshottari"]["rows"]:
|
|
_assert_instant(row["start"])
|
|
for event in package["slow_transits"]["events"]:
|
|
_assert_instant(event["instant"])
|
|
assert package["report_range"]["start"]["local"] == "2026-01-01T00:00:00+08:00"
|
|
assert package["report_range"]["start"]["timezone_id"] == "Asia/Shanghai"
|
|
|
|
|
|
# ── parameters reach the producers ─────────────────────────────────────────
|
|
|
|
def _return_utc(package: dict, year: int) -> datetime:
|
|
chart = next(item for item in package["year_charts"] if item["target_year"] == year)
|
|
return _utc(chart["solar_return"]["instant"])
|
|
|
|
|
|
def test_birth_second_reaches_return_natal_and_dasha() -> None:
|
|
base, later = _package(), _package(second=59)
|
|
assert later["parameters"]["birth"]["second"] == 59
|
|
assert later["parameters"]["birth"]["instant"]["local"] == "1990-06-15T12:00:59+08:00"
|
|
shift = (_return_utc(later, 2026) - _return_utc(base, 2026)).total_seconds()
|
|
assert 40 <= shift <= 80 # the natal Sun moves with the birth clock
|
|
moon0 = base["parameters"]["natal_chart"]["longitudes"]["Moon"]
|
|
moon1 = later["parameters"]["natal_chart"]["longitudes"]["Moon"]
|
|
assert moon1 > moon0
|
|
pd0 = base["natal_periods"]["vimshottari"]["rows"][-1]["end"]["utc"]
|
|
pd1 = later["natal_periods"]["vimshottari"]["rows"][-1]["end"]["utc"]
|
|
assert pd0 != pd1
|
|
|
|
|
|
def test_node_mode_reaches_annual_chart_monthly_charts_and_transits() -> None:
|
|
mean, true = _package(), _package(node_mode="true")
|
|
assert true["parameters"]["node_mode"] == "true"
|
|
chart_mean = mean["year_charts"][-1]["blocks"]["chart"]
|
|
chart_true = true["year_charts"][-1]["blocks"]["chart"]
|
|
assert chart_true["node_mode_of_chart"] == "true"
|
|
assert chart_mean["planets"]["Rahu"]["longitude"] != chart_true["planets"]["Rahu"]["longitude"]
|
|
# Nodes only: the return instant and the seven planets do not move.
|
|
assert chart_mean["planets"]["Jupiter"]["longitude"] == chart_true["planets"]["Jupiter"]["longitude"]
|
|
month_mean = mean["year_charts"][-1]["blocks"]["monthly_charts"]["rows"][3]["planet_longitudes"]["Rahu"]
|
|
month_true = true["year_charts"][-1]["blocks"]["monthly_charts"]["rows"][3]["planet_longitudes"]["Rahu"]
|
|
assert month_mean != month_true
|
|
rahu_mean = mean["slow_transits"]["positions_at_range_start"]["Rahu"]["longitude"]
|
|
rahu_true = true["slow_transits"]["positions_at_range_start"]["Rahu"]["longitude"]
|
|
assert rahu_mean != rahu_true
|
|
assert true["slow_transits"]["node_mode"] == "true"
|
|
|
|
|
|
def test_ayanamsa_reaches_natal_chart() -> None:
|
|
raman, lahiri = _package(), _package(ayanamsa="lahiri")
|
|
assert lahiri["parameters"]["ayanamsa"] == "lahiri"
|
|
lagna_raman = raman["parameters"]["natal_chart"]["longitudes"]["Lagna"]
|
|
lagna_lahiri = lahiri["parameters"]["natal_chart"]["longitudes"]["Lagna"]
|
|
assert lagna_raman - lagna_lahiri == pytest.approx(1.45, abs=0.2)
|
|
|
|
|
|
def test_return_location_keeps_the_field_and_offers_birth_place_only() -> None:
|
|
location = _package()["parameters"]["return_location"]
|
|
assert location["mode"] == "birth_place"
|
|
assert location["options_v1"] == ["birth_place"]
|
|
with pytest.raises(BadRequest):
|
|
_compute(_body(return_location={"mode": "current_location"}))
|
|
|
|
|
|
# ── year chart validity ────────────────────────────────────────────────────
|
|
|
|
def test_year_chart_validity_runs_from_this_return_to_the_next() -> None:
|
|
charts = _package()["year_charts"]
|
|
assert [item["target_year"] for item in charts] == [2025, 2026]
|
|
for chart in charts:
|
|
validity = chart["validity"]
|
|
assert validity["start"] == chart["solar_return"]["instant"]
|
|
assert validity["end_source"] == "next_solar_return"
|
|
assert validity["end_exclusive"] is True
|
|
days = (_utc(validity["end"]) - _utc(validity["start"])).total_seconds() / 86400
|
|
assert 365.2 < days < 365.3
|
|
assert validity["duration_days"] == pytest.approx(days, abs=1e-4)
|
|
rows = chart["blocks"]["monthly_charts"]["rows"]
|
|
assert len(rows) == 12
|
|
assert rows[0]["start"]["utc"] == validity["start"]["utc"]
|
|
assert rows[-1]["end"] == validity["end"]
|
|
assert rows[-1]["end_source"] == "next_solar_return"
|
|
assert all(a["end"] == b["start"] for a, b in zip(rows, rows[1:], strict=False))
|
|
assert charts[0]["validity"]["end"] == charts[1]["validity"]["start"]
|
|
assert charts[0]["blocks"]["monthly_charts"]["step"] == "solar_30_degrees"
|
|
|
|
|
|
# ── report range ───────────────────────────────────────────────────────────
|
|
|
|
def test_calendar_year_crossing_the_birthday_uses_both_year_charts_and_clips() -> None:
|
|
package = _package()
|
|
report_range = package["report_range"]
|
|
assert report_range["status"] == "computed"
|
|
assert report_range["year_chart_years"] == [2025, 2026]
|
|
assert report_range["crosses_birthday"] is True
|
|
birthday = report_range["birthday_boundaries"][0]
|
|
old, new = package["year_charts"]
|
|
assert birthday == new["validity"]["start"]
|
|
assert old["in_report_range"] == {"start": report_range["start"], "end": birthday}
|
|
assert new["in_report_range"] == {"start": birthday, "end": report_range["end"]}
|
|
range_start, range_end = _utc(report_range["start"]), _utc(report_range["end"])
|
|
for chart in (old, new):
|
|
for row in chart["blocks"]["mudda_dasha"]["periods"]:
|
|
start, end = _utc(row["start"]), _utc(row["end"])
|
|
clipped = row["in_report_range"]
|
|
if end <= range_start or start >= range_end:
|
|
assert clipped is None
|
|
else:
|
|
assert _utc(clipped["start"]) == max(start, range_start)
|
|
assert _utc(clipped["end"]) == min(end, range_end)
|
|
|
|
|
|
def test_birthday_year_is_one_chart_validity() -> None:
|
|
package = _package(report_range={"kind": "birthday_year", "year": 2026})
|
|
report_range = package["report_range"]
|
|
assert report_range["year_chart_years"] == [2026]
|
|
chart = package["year_charts"][0]
|
|
assert report_range["start"] == chart["validity"]["start"]
|
|
assert report_range["end"] == chart["validity"]["end"]
|
|
assert report_range["crosses_birthday"] is False
|
|
|
|
|
|
def test_next_twelve_months_defaults_to_the_observation_date() -> None:
|
|
package = _package(report_range={"kind": "next_12_months"})
|
|
report_range = package["report_range"]
|
|
assert report_range["requested"]["start_date"] == "2026-11-01"
|
|
assert report_range["start"]["local"] == "2026-11-01T00:00:00+08:00"
|
|
assert report_range["end"]["local"] == "2027-11-01T00:00:00+08:00"
|
|
assert report_range["year_chart_years"] == [2026, 2027]
|
|
assert report_range["observation"]["position"] == "inside_range"
|
|
assert report_range["observation"]["lookback_start"]["local"] == "2026-08-01T00:00:00+08:00"
|
|
|
|
|
|
def test_bad_range_inputs_are_bad_requests() -> None:
|
|
for bad in ({"kind": "lunar_year"}, {"kind": "calendar_year", "year": "abc"},
|
|
{"kind": "calendar_year", "start_date": "2026-01-01"}, "2026"):
|
|
with pytest.raises(BadRequest):
|
|
_compute(_body(report_range=bad))
|
|
|
|
|
|
# ── each period system keeps its own year ──────────────────────────────────
|
|
|
|
def test_patyayini_keeps_365_25_days_and_is_clipped_not_stretched() -> None:
|
|
package = _package()
|
|
range_end = _utc(package["report_range"]["end"])
|
|
chart = package["year_charts"][-1]
|
|
patyayini = chart["blocks"]["patyayini"]
|
|
year = patyayini["year_length"]
|
|
assert year["days"] == 365.25
|
|
assert year["basis"] == "fixed_365_25_days"
|
|
assert year["start"] == chart["validity"]["start"]
|
|
assert year["end"]["jd_ut"] - year["start"]["jd_ut"] == pytest.approx(365.25, abs=1e-6)
|
|
# Not stretched to the next return (the solar return year is about 365.256 days).
|
|
assert year["end"]["jd_ut"] < chart["validity"]["end"]["jd_ut"]
|
|
periods = patyayini["periods"]
|
|
assert len(periods) == 8 # Lagna + seven planets
|
|
assert periods[0]["start"] == year["start"]
|
|
assert abs((_utc(periods[-1]["end"]) - _utc(year["end"])).total_seconds()) <= 1
|
|
straddling = [row for row in periods if _utc(row["start"]) < range_end < _utc(row["end"])]
|
|
assert len(straddling) == 1
|
|
assert straddling[0]["in_report_range"]["end"] == package["report_range"]["end"]
|
|
assert all(row["in_report_range"] is None for row in periods if _utc(row["start"]) >= range_end)
|
|
assert len(patyayini["inputs"]) == 8
|
|
assert {row["point"] for row in patyayini["inputs"]} == {"Lagna", "Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn"}
|
|
|
|
|
|
def test_mudda_keeps_its_own_year_from_the_return() -> None:
|
|
chart = _package()["year_charts"][-1]
|
|
mudda = chart["blocks"]["mudda_dasha"]
|
|
assert mudda["year_length"]["days"] == 365.25
|
|
assert mudda["year_length"]["start"] == chart["validity"]["start"]
|
|
assert mudda["year_length"]["end"]["jd_ut"] - mudda["year_length"]["start"]["jd_ut"] == pytest.approx(365.25, abs=1e-6)
|
|
assert mudda["periods"][0]["start"] == chart["validity"]["start"]
|
|
assert mudda["starting_lord"] == mudda["periods"][0]["lord"]
|
|
|
|
|
|
def test_natal_periods_overlap_the_range() -> None:
|
|
package = _package()
|
|
range_start, range_end = _utc(package["report_range"]["start"]), _utc(package["report_range"]["end"])
|
|
vim = package["natal_periods"]["vimshottari"]
|
|
assert vim["status"] == "computed"
|
|
assert vim["year_length"]["days"] == 365.25
|
|
assert {row["level"] for row in vim["rows"]} == {"MD", "AD", "PD"}
|
|
for row in vim["rows"]:
|
|
assert _utc(row["start"]) < range_end and _utc(row["end"]) > range_start
|
|
pds = [row for row in vim["rows"] if row["level"] == "PD"]
|
|
assert _utc(pds[0]["start"]) <= range_start and _utc(pds[-1]["end"]) >= range_end
|
|
narayana = package["natal_periods"]["narayana"]
|
|
assert narayana["age_axis"]["year_days"] == 365.25
|
|
assert narayana["legacy"]["used_by_product_timeline"] is True
|
|
assert narayana["rath"]["used_by_product_timeline"] is False
|
|
for profile in ("legacy", "rath"):
|
|
if narayana[profile]["status"] == "computed":
|
|
assert {row["level"] for row in narayana[profile]["rows"]} == {"MD", "AD"}
|
|
|
|
|
|
def test_slow_transits_events_are_exact_and_inside_the_range() -> None:
|
|
package = _package()
|
|
transits = package["slow_transits"]
|
|
assert transits["status"] == "computed"
|
|
assert transits["bodies"] == ["Jupiter", "Saturn", "Rahu", "Ketu"]
|
|
range_start, range_end = _utc(package["report_range"]["start"]), _utc(package["report_range"]["end"])
|
|
jds = [event["instant"]["jd_ut"] for event in transits["events"]]
|
|
assert jds == sorted(jds)
|
|
for event in transits["events"]:
|
|
assert range_start <= _utc(event["instant"]) < range_end
|
|
assert event["kind"] in ("ingress", "station")
|
|
if event["kind"] == "ingress":
|
|
assert event["from_sign"] != event["to_sign"]
|
|
assert 1 <= event["to_house_from_lagna"] <= 12
|
|
assert 1 <= event["to_house_from_moon"] <= 12
|
|
for loop in transits["retrograde_loops"]:
|
|
assert loop["body"] in ("Jupiter", "Saturn")
|
|
assert _utc(loop["station_retrograde"]["instant"]) < _utc(loop["station_direct"]["instant"])
|
|
for contact in transits["natal_point_contacts"]:
|
|
assert contact["count"] == len(contact["crossings"])
|
|
|
|
|
|
# ── blocked blocks do not fail the response ────────────────────────────────
|
|
|
|
def _fresh(**overrides) -> dict:
|
|
return _compute(_body(**overrides))
|
|
|
|
|
|
def test_blocked_patyayini_and_rath_do_not_fail_the_response(monkeypatch) -> None:
|
|
import narayana_rath
|
|
from annual_native import patyayini
|
|
|
|
def broken(**_kwargs):
|
|
raise ValueError("forced failure")
|
|
|
|
def broken_rath(*_args, **_kwargs):
|
|
raise RuntimeError("forced rath failure")
|
|
|
|
monkeypatch.setattr(patyayini, "build_native_patyayini", broken)
|
|
monkeypatch.setattr(narayana_rath, "calc_narayana_rath", broken_rath)
|
|
result = _fresh(report_range={"kind": "birthday_year", "year": 2026})
|
|
assert result["success"] is True
|
|
package = result["package"]
|
|
blocks = package["year_charts"][0]["blocks"]
|
|
assert blocks["patyayini"]["status"] == "blocked"
|
|
assert "forced failure" in blocks["patyayini"]["reason"]
|
|
assert blocks["mudda_dasha"]["status"] == "computed"
|
|
assert package["natal_periods"]["narayana"]["rath"]["status"] == "blocked"
|
|
assert package["natal_periods"]["narayana"]["legacy"]["status"] == "computed"
|
|
paths = {row["path"] for row in package["block_index"] if row["status"] == "blocked"}
|
|
assert "year_charts[0].blocks.patyayini" in paths
|
|
assert package["blocked_count"] >= 2
|
|
|
|
|
|
def test_blocked_slow_transits_do_not_fail_the_response(monkeypatch) -> None:
|
|
import jyotish_engine
|
|
|
|
def broken(*_args, **_kwargs):
|
|
raise RuntimeError("ephemeris unavailable")
|
|
|
|
monkeypatch.setattr(jyotish_engine, "_calc_sidereal_planets_for_jd", broken)
|
|
result = _fresh(report_range={"kind": "birthday_year", "year": 2026})
|
|
assert result["success"] is True
|
|
transits = result["package"]["slow_transits"]
|
|
assert transits["status"] == "blocked"
|
|
assert "ephemeris unavailable" in transits["reason"]
|
|
assert result["package"]["year_charts"][0]["status"] == "computed"
|
|
|
|
|
|
# ── thin registration; the default /api/annual shape is covered by test_api_server_security ──
|
|
|
|
def test_route_is_registered_thinly() -> None:
|
|
source = (Path(SCRIPTS) / "jyotish_api_server.py").read_text(encoding="utf-8")
|
|
assert "elif path == '/api/annual/package':" in source
|
|
assert "elif path == '/api/annual/report_facts':" in source
|
|
assert "_load_local_module('annual_service.api')" in source
|
|
assert "_annual_service('compute_annual_package', body)" in source
|
|
assert "def _compute_annual_package" not in source
|
|
assert "def _compute_annual_report_facts" not in source
|
|
|
|
|
|
def test_solar_return_node_mode_default_stays_mean() -> None:
|
|
import solar_return
|
|
|
|
args = (1990, 6, 15, 12, 0, 39.9042, 116.4074, 8.0, 2026)
|
|
default = solar_return.calc_solar_return_chart(*args, ayanamsa_name="raman")
|
|
true = solar_return.calc_solar_return_chart(*args, ayanamsa_name="raman", node_mode="true")
|
|
assert default["node_mode"] == "mean"
|
|
assert true["node_mode"] == "true"
|
|
assert default["planet_lons"]["Rahu"] != true["planet_lons"]["Rahu"]
|
|
assert default["solar_return"]["jd_ut"] == true["solar_return"]["jd_ut"]
|
|
assert default["planet_lons"]["Saturn"] == true["planet_lons"]["Saturn"]
|
|
|
|
|
|
# ── pure helpers ───────────────────────────────────────────────────────────
|
|
|
|
def test_instant_uses_the_zone_offset_at_that_moment() -> None:
|
|
zone = annual_package._zone("America/New_York", -5)
|
|
summer = annual_package.instant(datetime(2026, 7, 1, 12, tzinfo=UTC), zone)
|
|
winter = annual_package.instant(datetime(2026, 1, 1, 12, tzinfo=UTC), zone)
|
|
assert summer["utc_offset"] == "-04:00"
|
|
assert winter["utc_offset"] == "-05:00"
|
|
fixed = annual_package.instant(datetime(2026, 7, 1, 12, tzinfo=UTC), annual_package._zone(None, 5.5))
|
|
assert fixed["local"] == "2026-07-01T17:30:00+05:30"
|
|
assert fixed["timezone_id"] is None
|
|
|
|
|
|
def test_elapsed_mapper_counts_days_across_daylight_saving() -> None:
|
|
anchor_utc = datetime(2026, 3, 1, 12, tzinfo=UTC)
|
|
convert = annual_package._elapsed_mapper(datetime(2026, 3, 1, 7), anchor_utc)
|
|
assert convert("2026-04-01 07:00:00") == anchor_utc + timedelta(days=31)
|
|
assert convert(None) is None
|
|
|
|
|
|
def test_add_months_clamps_to_month_end() -> None:
|
|
from datetime import date
|
|
|
|
assert annual_package._add_months(date(2028, 2, 29), 12) == date(2029, 2, 28)
|
|
assert annual_package._add_months(date(2026, 11, 1), -3) == date(2026, 8, 1)
|
|
assert annual_package._add_months(date(2026, 1, 31), 1) == date(2026, 2, 28)
|