"""Tests for the real-astronomy layer. These check the maths against genuinely known astronomical events rather than against the implementation's own output — a test that only asserts "the function returns what the function returns" would happily pass on completely wrong orbital mechanics. """ from datetime import datetime, timedelta, timezone import pytest from app.celestial import ( PHASE_NAMES, SYNODIC_MONTH_DAYS, moon_illumination, moon_phase, moon_phase_name, solar_midnight, veil_thinness, witching_proximity, ) def _utc(y, m, d, hh=0, mm=0): return datetime(y, m, d, hh, mm, tzinfo=timezone.utc) class TestMoonPhase: def test_epoch_new_moon_reads_as_new(self): # The reference epoch itself must land on ~0. assert moon_phase(_utc(2000, 1, 6, 18, 14)) == pytest.approx(0.0, abs=0.01) def test_known_full_moons_read_as_full(self): # Real full moons, from published ephemerides. Tolerance is ~half a # day in phase terms, which is what the mean-synodic approximation # honestly supports. for when in ( _utc(2024, 1, 25, 17, 54), _utc(2024, 8, 19, 18, 26), _utc(2025, 3, 14, 6, 55), ): assert moon_phase(when) == pytest.approx(0.5, abs=0.04), when def test_known_new_moons_read_as_new(self): for when in ( _utc(2024, 2, 9, 22, 59), _utc(2024, 9, 3, 1, 56), _utc(2025, 3, 29, 10, 58), ): phase = moon_phase(when) # New moon sits at the 0/1 wraparound, so accept either end. assert min(phase, 1 - phase) == pytest.approx(0.0, abs=0.04), when def test_phase_always_in_unit_range(self): when = _utc(2024, 1, 1) for i in range(400): p = moon_phase(when + timedelta(days=i)) assert 0.0 <= p < 1.0 def test_advances_a_full_cycle_over_one_synodic_month(self): start = _utc(2024, 6, 1) later = start + timedelta(days=SYNODIC_MONTH_DAYS) assert moon_phase(start) == pytest.approx(moon_phase(later), abs=0.001) def test_naive_datetime_is_treated_as_utc_rather_than_raising(self): # A summon must never fail because a caller forgot a tzinfo. naive = datetime(2024, 1, 25, 17, 54) assert moon_phase(naive) == pytest.approx(0.5, abs=0.04) class TestPhaseNames: def test_new_and_full_map_to_the_right_names(self): assert moon_phase_name(0.0) == "new moon" assert moon_phase_name(0.5) == "full moon" def test_quarters_map_to_the_right_names(self): assert moon_phase_name(0.25) == "first quarter" assert moon_phase_name(0.75) == "last quarter" def test_every_phase_yields_a_known_name(self): for i in range(100): assert moon_phase_name(i / 100) in PHASE_NAMES def test_names_progress_in_order_across_a_cycle(self): seen = [] for i in range(64): name = moon_phase_name(i / 64) if not seen or seen[-1] != name: seen.append(name) # Starts and ends on "new moon" (the cycle wraps), covering all 8. assert set(seen) == set(PHASE_NAMES) class TestIllumination: def test_new_moon_is_dark_and_full_moon_is_lit(self): assert moon_illumination(0.0) == pytest.approx(0.0, abs=1e-9) assert moon_illumination(0.5) == pytest.approx(1.0, abs=1e-9) def test_quarters_are_half_lit(self): assert moon_illumination(0.25) == pytest.approx(0.5, abs=1e-9) assert moon_illumination(0.75) == pytest.approx(0.5, abs=1e-9) def test_always_in_unit_range(self): for i in range(200): v = moon_illumination(i / 200) assert 0.0 <= v <= 1.0 class TestSolarMidnight: def test_greenwich_midnight_is_utc_midnight(self): got = solar_midnight(0.0, _utc(2024, 6, 15, 23, 0)) assert got.hour == 0 and got.minute == 0 def test_longitude_shifts_midnight_by_an_hour_per_15_degrees(self): at_zero = solar_midnight(0.0, _utc(2024, 6, 15, 12, 0)) at_fifteen_east = solar_midnight(15.0, _utc(2024, 6, 15, 12, 0)) delta_hours = (at_zero - at_fifteen_east).total_seconds() / 3600 assert delta_hours == pytest.approx(1.0, abs=0.01) def test_returns_the_nearest_midnight_not_a_stale_one(self): # Just before local midnight the answer must be the one ahead, # never the one ~24h behind. when = _utc(2024, 6, 15, 23, 50) assert abs((solar_midnight(0.0, when) - when).total_seconds()) < 3600 class TestWitchingProximity: def test_peaks_at_solar_midnight(self): when = _utc(2024, 6, 15, 0, 0) assert witching_proximity(0.0, when) == pytest.approx(1.0, abs=0.01) def test_bottoms_out_at_solar_noon(self): when = _utc(2024, 6, 15, 12, 0) assert witching_proximity(0.0, when) == pytest.approx(0.0, abs=0.01) def test_always_in_unit_range_around_the_clock(self): base = _utc(2024, 6, 15) for hour in range(48): v = witching_proximity(0.0, base + timedelta(hours=hour)) assert 0.0 <= v <= 1.0 def test_accounts_for_the_seekers_longitude(self): # 03:00 UTC is the dead of night at Greenwich but not in Tokyo. when = _utc(2024, 6, 15, 3, 0) assert witching_proximity(0.0, when) > witching_proximity(139.7, when) class TestVeilThinness: def test_reports_all_components(self): r = veil_thinness(0.0, _utc(2024, 1, 25, 0, 0)) assert set(r) == { "moon_phase", "moon_name", "moon_illumination", "witching_proximity", "thinness", } def test_moon_only_when_location_is_unknown(self): r = veil_thinness(None, _utc(2024, 1, 25, 17, 54)) assert r["witching_proximity"] is None # A full moon with no location should still read as thin. assert r["thinness"] > 0.9 def test_full_moon_at_solar_midnight_is_thinner_than_new_moon_at_noon(self): best = veil_thinness(0.0, _utc(2024, 1, 25, 0, 0)) worst = veil_thinness(0.0, _utc(2024, 2, 9, 12, 0)) assert best["thinness"] > worst["thinness"] def test_thinness_always_in_unit_range(self): base = _utc(2024, 1, 1) for i in range(0, 400, 7): for lon in (-180.0, -75.0, 0.0, 139.7, 180.0): v = veil_thinness(lon, base + timedelta(days=i, hours=i % 24)) assert 0.0 <= v["thinness"] <= 1.0