""" Unit tests for the pure astrological calculation module. Tests cover: - Zodiac sign calculations - House system calculations (Placidus, Equal, Whole Sign) - Aspect detection with orbs - Angle calculations (ASC, MC, DSC, IC) - House placement - Retrograde detection - Composite / Davison charts """ from __future__ import annotations import math import pytest from src.astro_mcp.astrology import ( DEFAULT_ORBS, SUPPORTED_HOUSE_SYSTEMS, ZODIAC_SIGNS, calculate_angles, calculate_houses, compute_aspects, compute_composite_chart, ecliptic_to_zodiac, get_house_placement, is_retrograde, normalize_degrees, zodiac_to_ecliptic, ) # ── normalize_degrees ──────────────────────────────────────────────── class TestNormalizeDegrees: def test_zero(self): assert normalize_degrees(0.0) == 0.0 def test_positive(self): assert normalize_degrees(45.0) == 45.0 def test_360_wraps_to_zero(self): assert normalize_degrees(360.0) == 0.0 def test_over_360(self): assert normalize_degrees(370.0) == 10.0 def test_negative(self): assert normalize_degrees(-10.0) == 350.0 def test_large_negative(self): assert normalize_degrees(-370.0) == 350.0 def test_720(self): assert normalize_degrees(720.0) == 0.0 # ── ecliptic_to_zodiac ────────────────────────────────────────────── class TestEclipticToZodiac: def test_aries_start(self): result = ecliptic_to_zodiac(0.0) assert result["sign"] == "Aries" assert result["degree"] == 0.0 def test_aries_mid(self): result = ecliptic_to_zodiac(15.0) assert result["sign"] == "Aries" assert result["degree"] == 15.0 def test_taurus_start(self): result = ecliptic_to_zodiac(30.0) assert result["sign"] == "Taurus" assert result["degree"] == 0.0 def test_pisces_end(self): result = ecliptic_to_zodiac(359.0) assert result["sign"] == "Pisces" assert abs(result["degree"] - 29.0) < 0.001 def test_all_signs(self): for i, sign in enumerate(ZODIAC_SIGNS): lon = i * 30 + 15 # middle of each sign result = ecliptic_to_zodiac(lon) assert result["sign"] == sign assert abs(result["degree"] - 15.0) < 0.001 def test_abbreviation(self): result = ecliptic_to_zodiac(0.0) assert result["abbreviation"] == "Ar" def test_absolute_lon_preserved(self): result = ecliptic_to_zodiac(45.5) assert result["absolute_lon"] == 45.5 # ── zodiac_to_ecliptic (roundtrip) ────────────────────────────────── class TestZodiacToEcliptic: def test_roundtrip_all_signs(self): for i, sign in enumerate(ZODIAC_SIGNS): for deg in [0.0, 15.0, 29.9]: lon = zodiac_to_ecliptic(sign, deg) result = ecliptic_to_zodiac(lon) assert result["sign"] == sign assert abs(result["degree"] - deg) < 0.01 # ── Aspect Detection ──────────────────────────────────────────────── class TestComputeAspects: def test_conjunction(self): bodies = [ {"name": "sun", "lon": 10.0}, {"name": "moon", "lon": 12.0}, ] aspects = compute_aspects(bodies) assert len(aspects) == 1 assert aspects[0]["aspect"] == "conjunction" assert aspects[0]["orb"] == 2.0 assert aspects[0]["body1"] == "sun" assert aspects[0]["body2"] == "moon" def test_opposition(self): bodies = [ {"name": "sun", "lon": 0.0}, {"name": "moon", "lon": 180.0}, ] aspects = compute_aspects(bodies) assert any(a["aspect"] == "opposition" and a["orb"] == 0.0 for a in aspects) def test_trine(self): bodies = [ {"name": "sun", "lon": 0.0}, {"name": "jupiter", "lon": 120.0}, ] aspects = compute_aspects(bodies) assert any(a["aspect"] == "trine" for a in aspects) def test_square(self): bodies = [ {"name": "sun", "lon": 0.0}, {"name": "mars", "lon": 90.0}, ] aspects = compute_aspects(bodies) assert any(a["aspect"] == "square" for a in aspects) def test_sextile(self): bodies = [ {"name": "sun", "lon": 0.0}, {"name": "venus", "lon": 60.0}, ] aspects = compute_aspects(bodies) assert any(a["aspect"] == "sextile" for a in aspects) def test_no_aspects_beyond_orb(self): bodies = [ {"name": "sun", "lon": 0.0}, {"name": "moon", "lon": 45.0}, ] aspects = compute_aspects(bodies) assert len(aspects) == 0 def test_multiple_bodies(self): bodies = [ {"name": "sun", "lon": 0.0}, {"name": "moon", "lon": 2.0}, {"name": "mars", "lon": 5.0}, ] aspects = compute_aspects(bodies) # sun-moon conjunction, sun-mars conjunction, moon-mars conjunction assert len(aspects) >= 2 def test_sorted_by_orb(self): bodies = [ {"name": "sun", "lon": 0.0}, {"name": "moon", "lon": 1.0}, {"name": "mars", "lon": 5.0}, ] aspects = compute_aspects(bodies) orbs = [a["orb"] for a in aspects] assert orbs == sorted(orbs) def test_custom_orbs(self): bodies = [ {"name": "sun", "lon": 0.0}, {"name": "moon", "lon": 10.0}, ] # Default conjunction orb is 8, so 10° should not aspect aspects_default = compute_aspects(bodies) assert len(aspects_default) == 0 # With wider orb, it should aspects_wide = compute_aspects(bodies, orb_limits={"conjunction": 12.0}) assert len(aspects_wide) == 1 def test_exactness(self): bodies = [ {"name": "sun", "lon": 0.0}, {"name": "moon", "lon": 0.0}, ] aspects = compute_aspects(bodies) assert aspects[0]["exactness"] == 1.0 def test_applying_with_speeds(self): # Moon is behind sun but faster -- catching up = applying bodies = [ {"name": "sun", "lon": 10.0, "speed_lon": 1.0}, {"name": "moon", "lon": 5.0, "speed_lon": 13.0}, ] aspects = compute_aspects(bodies) conj = [a for a in aspects if a["aspect"] == "conjunction"] assert len(conj) == 1 assert conj[0]["applying"] is True def test_separating_with_speeds(self): # Sun is ahead of moon and faster -- pulling away = separating bodies = [ {"name": "sun", "lon": 10.0, "speed_lon": 13.0}, {"name": "moon", "lon": 5.0, "speed_lon": 1.0}, ] aspects = compute_aspects(bodies) conj = [a for a in aspects if a["aspect"] == "conjunction"] assert len(conj) == 1 assert conj[0]["applying"] is False def test_applying_none_without_speeds(self): bodies = [ {"name": "sun", "lon": 0.0}, {"name": "moon", "lon": 5.0}, ] aspects = compute_aspects(bodies) conj = [a for a in aspects if a["aspect"] == "conjunction"] assert conj[0]["applying"] is None def test_wraparound_360(self): bodies = [ {"name": "sun", "lon": 359.0}, {"name": "moon", "lon": 1.0}, ] aspects = compute_aspects(bodies) conj = [a for a in aspects if a["aspect"] == "conjunction"] assert len(conj) == 1 assert abs(conj[0]["orb"] - 2.0) < 0.01 # ── House Systems ─────────────────────────────────────────────────── class TestCalculateHouses: def test_all_systems_return_12_cusps(self): for system in SUPPORTED_HOUSE_SYSTEMS: houses = calculate_houses(6.0, 45.0, house_system=system) assert len(houses) == 12 def test_all_systems_have_required_keys(self): for system in SUPPORTED_HOUSE_SYSTEMS: houses = calculate_houses(6.0, 45.0, house_system=system) for h in houses: assert "house" in h assert "sign" in h assert "abbreviation" in h assert "degree" in h assert "absolute_lon" in h def test_house_numbers_1_to_12(self): for system in SUPPORTED_HOUSE_SYSTEMS: houses = calculate_houses(6.0, 45.0, house_system=system) numbers = [h["house"] for h in houses] assert sorted(numbers) == list(range(1, 13)) def test_equal_house_30_degree_spacing(self): houses = calculate_houses(6.0, 45.0, house_system="equal") for i in range(11): lon1 = houses[i]["absolute_lon"] lon2 = houses[i + 1]["absolute_lon"] diff = normalize_degrees(lon2 - lon1) assert abs(diff - 30.0) < 0.01 def test_whole_sign_each_sign_is_one_house(self): houses = calculate_houses(6.0, 45.0, house_system="whole_sign") for i, h in enumerate(houses): sign_index = int(h["absolute_lon"] // 30) expected_sign = ZODIAC_SIGNS[sign_index] assert h["sign"] == expected_sign assert h["degree"] == 0.0 def test_unsupported_system_raises(self): with pytest.raises(ValueError, match="Unsupported house system"): calculate_houses(6.0, 45.0, house_system="campanus") def test_placidus_asc_is_house_1(self): houses = calculate_houses(6.0, 45.0, house_system="placidus") h1 = [h for h in houses if h["house"] == 1][0] assert h1["absolute_lon"] is not None def test_placidus_mc_is_house_10(self): houses = calculate_houses(6.0, 45.0, house_system="placidus") h10 = [h for h in houses if h["house"] == 10][0] assert h10["absolute_lon"] is not None # ── Angles ────────────────────────────────────────────────────────── class TestCalculateAngles: def test_all_angles_present(self): angles = calculate_angles(6.0, 45.0) assert "ascendant" in angles assert "midheaven" in angles assert "descendant" in angles assert "imum_coeli" in angles def test_asc_opposite_dsc(self): angles = calculate_angles(6.0, 45.0) asc_lon = angles["ascendant"]["absolute_lon"] dsc_lon = angles["descendant"]["absolute_lon"] diff = abs(asc_lon - dsc_lon) assert abs(diff - 180.0) < 1.0 # within 1 degree def test_mc_opposite_ic(self): angles = calculate_angles(6.0, 45.0) mc_lon = angles["midheaven"]["absolute_lon"] ic_lon = angles["imum_coeli"]["absolute_lon"] diff = abs(mc_lon - ic_lon) assert abs(diff - 180.0) < 1.0 def test_angles_have_sign_and_degree(self): angles = calculate_angles(6.0, 45.0) for key in ("ascendant", "midheaven", "descendant", "imum_coeli"): assert "sign" in angles[key] assert "degree" in angles[key] assert "absolute_lon" in angles[key] # ── House Placement ───────────────────────────────────────────────── class TestGetHousePlacement: def test_simple_equal_houses(self): # At LST=6h, lat=0, ASC is near 90° (Cancer). House 1 = 90-120. houses = calculate_houses(6.0, 0.0, house_system="equal") asc_lon = houses[0]["absolute_lon"] # Place a point 15° after the ASC -- should be in house 1 test_lon = normalize_degrees(asc_lon + 15.0) assert get_house_placement(test_lon, houses) == 1 def test_350_in_last_house(self): # At LST=0, lat=0, whole sign ASC = 90° (Cancer). # Houses: 1=Cn(90), 2=Le(120), 3=Vi(150), 4=Li(180), 5=Sc(210), # 6=Sg(240), 7=Cap(270), 8=Aq(300), 9=Pi(330), 10=Ar(0/360)... # House 9 = Pisces (330-360). 350° is in house 9. houses = calculate_houses(0.0, 0.0, house_system="whole_sign") assert get_house_placement(350.0, houses) == 9 def test_0_degrees(self): houses = calculate_houses(0.0, 0.0, house_system="equal") result = get_house_placement(0.0, houses) assert 1 <= result <= 12 def test_180_degrees(self): houses = calculate_houses(0.0, 0.0, house_system="equal") result = get_house_placement(180.0, houses) assert 1 <= result <= 12 # ── Retrograde ────────────────────────────────────────────────────── class TestIsRetrograde: def test_positive_speed_direct(self): assert is_retrograde(1.0) is False def test_negative_speed_retrograde(self): assert is_retrograde(-0.5) is True def test_zero_speed_not_retrograde(self): assert is_retrograde(0.0) is False def test_none_speed_not_retrograde(self): assert is_retrograde(None) is False # ── Composite Chart ───────────────────────────────────────────────── class TestComputeCompositeChart: def test_basic_midpoint(self): b1 = [{"name": "sun", "lon": 10.0}] b2 = [{"name": "sun", "lon": 20.0}] composite = compute_composite_chart(b1, b2) assert len(composite) == 1 assert abs(composite[0]["lon"] - 15.0) < 0.01 def test_wraparound_midpoint(self): b1 = [{"name": "sun", "lon": 350.0}] b2 = [{"name": "sun", "lon": 10.0}] composite = compute_composite_chart(b1, b2) # Midpoint of 350 and 10 should be 0 (or 360) assert abs(composite[0]["lon"] - 0.0) < 1.0 or abs(composite[0]["lon"] - 360.0) < 1.0 def test_only_common_bodies(self): b1 = [{"name": "sun", "lon": 10.0}, {"name": "mars", "lon": 20.0}] b2 = [{"name": "sun", "lon": 30.0}, {"name": "venus", "lon": 40.0}] composite = compute_composite_chart(b1, b2) assert len(composite) == 1 assert composite[0]["name"] == "sun" def test_empty_when_no_common(self): b1 = [{"name": "sun", "lon": 10.0}] b2 = [{"name": "moon", "lon": 20.0}] composite = compute_composite_chart(b1, b2) assert len(composite) == 0 # ── Default Orbs ──────────────────────────────────────────────────── class TestDefaultOrbs: def test_conjunction_orb(self): assert DEFAULT_ORBS["conjunction"] == 8.0 def test_sextile_orb(self): assert DEFAULT_ORBS["sextile"] == 6.0 def test_square_orb(self): assert DEFAULT_ORBS["square"] == 8.0 def test_trine_orb(self): assert DEFAULT_ORBS["trine"] == 8.0 def test_opposition_orb(self): assert DEFAULT_ORBS["opposition"] == 8.0 # ── Supported House Systems ───────────────────────────────────────── class TestSupportedHouseSystems: def test_placidus_supported(self): assert "placidus" in SUPPORTED_HOUSE_SYSTEMS def test_equal_supported(self): assert "equal" in SUPPORTED_HOUSE_SYSTEMS def test_whole_sign_supported(self): assert "whole_sign" in SUPPORTED_HOUSE_SYSTEMS