""" 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 from typing import Any import math import pytest from src.astro_mcp.astrology import ( ANGULAR_HOUSES, CADENT_HOUSES, DEFAULT_ORBS, ELEMENT_SIGNS, HARD_ASPECTS, MODALITY_SIGNS, PERSONAL_PLANETS, SIGN_ABBREVIATIONS, SIGN_ELEMENTS, SIGN_MODALITIES, SIGN_RULERS, SIGN_RULERS_TRADITIONAL, SUCCEDENT_HOUSES, SUPPORTED_HOUSE_SYSTEMS, ZODIAC_SIGNS, calculate_angles, calculate_houses, compute_aspects, compute_composite_chart, detect_aspect_patterns, detect_chart_shape, detect_stelliums, ecliptic_to_zodiac, filter_aspects_by_planets, get_chart_ruler, get_element_balance, get_empty_houses, get_hemisphere_emphasis, get_house_placement, get_house_rulers, get_house_type_counts, get_modality_balance, get_nodal_axis, get_natal_aspects_to_planets, get_part_of_fortune, get_pluto_polarity_point, get_retrograde_planets, get_saturn_info, get_twelfth_house_analysis, group_planets_by_house, group_planets_by_sign, 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 # ── Sign Ruler Lookup ──────────────────────────────────────────────── class TestSignRulers: def test_aries_ruler(self): assert SIGN_RULERS["Aries"] == "mars" def test_scorpius_ruler_modern(self): assert SIGN_RULERS["Scorpius"] == "pluto" def test_scorpius_ruler_traditional(self): assert SIGN_RULERS_TRADITIONAL["Scorpius"] == "mars" def test_aquarius_ruler_modern(self): assert SIGN_RULERS["Aquarius"] == "uranus" def test_aquarius_ruler_traditional(self): assert SIGN_RULERS_TRADITIONAL["Aquarius"] == "saturn" def test_all_signs_have_rulers(self): for sign in ZODIAC_SIGNS: assert sign in SIGN_RULERS assert sign in SIGN_RULERS_TRADITIONAL class TestSignElements: def test_fire_signs(self): assert set(ELEMENT_SIGNS["fire"]) == {"Aries", "Leo", "Sagittarius"} def test_earth_signs(self): assert set(ELEMENT_SIGNS["earth"]) == {"Taurus", "Virgo", "Capricornus"} def test_air_signs(self): assert set(ELEMENT_SIGNS["air"]) == {"Gemini", "Libra", "Aquarius"} def test_water_signs(self): assert set(ELEMENT_SIGNS["water"]) == {"Cancer", "Scorpius", "Pisces"} def test_all_signs_have_elements(self): for sign in ZODIAC_SIGNS: assert sign in SIGN_ELEMENTS class TestSignModalities: def test_cardinal_signs(self): assert set(MODALITY_SIGNS["cardinal"]) == {"Aries", "Cancer", "Libra", "Capricornus"} def test_fixed_signs(self): assert set(MODALITY_SIGNS["fixed"]) == {"Taurus", "Leo", "Scorpius", "Aquarius"} def test_mutable_signs(self): assert set(MODALITY_SIGNS["mutable"]) == {"Gemini", "Virgo", "Sagittarius", "Pisces"} def test_all_signs_have_modalities(self): for sign in ZODIAC_SIGNS: assert sign in SIGN_MODALITIES # ── Helper Constants ───────────────────────────────────────────────── class TestHelperConstants: def test_angular_houses(self): assert ANGULAR_HOUSES == {1, 4, 7, 10} def test_succedent_houses(self): assert SUCCEDENT_HOUSES == {2, 5, 8, 11} def test_cadent_houses(self): assert CADENT_HOUSES == {3, 6, 9, 12} def test_personal_planets(self): assert PERSONAL_PLANETS == {"sun", "moon", "mercury", "venus", "mars"} def test_hard_aspects(self): assert HARD_ASPECTS == {"conjunction", "square", "opposition"} # ── Sample Planet Fixtures ─────────────────────────────────────────── def _make_planets() -> list[dict[str, Any]]: """Create a realistic 12-planet list for testing.""" return [ {"body": "sun", "sign": "Leo", "house": 5, "absolute_lon": 135.0, "degree_within_sign": 15.0, "retrograde": False}, {"body": "moon", "sign": "Cancer", "house": 4, "absolute_lon": 105.0, "degree_within_sign": 15.0, "retrograde": False}, {"body": "mercury", "sign": "Leo", "house": 5, "absolute_lon": 138.0, "degree_within_sign": 18.0, "retrograde": False}, {"body": "venus", "sign": "Virgo", "house": 6, "absolute_lon": 155.0, "degree_within_sign": 5.0, "retrograde": False}, {"body": "mars", "sign": "Aries", "house": 1, "absolute_lon": 15.0, "degree_within_sign": 15.0, "retrograde": False}, {"body": "jupiter", "sign": "Sagittarius", "house": 9, "absolute_lon": 255.0, "degree_within_sign": 15.0, "retrograde": True}, {"body": "saturn", "sign": "Capricornus", "house": 10, "absolute_lon": 285.0, "degree_within_sign": 15.0, "retrograde": False}, {"body": "uranus", "sign": "Aquarius", "house": 11, "absolute_lon": 315.0, "degree_within_sign": 15.0, "retrograde": False}, {"body": "neptune", "sign": "Pisces", "house": 12, "absolute_lon": 345.0, "degree_within_sign": 15.0, "retrograde": False}, {"body": "pluto", "sign": "Scorpius", "house": 8, "absolute_lon": 225.0, "degree_within_sign": 15.0, "retrograde": True}, {"body": "chiron", "sign": "Aries", "house": 1, "absolute_lon": 12.0, "degree_within_sign": 12.0, "retrograde": False}, {"body": "true_node", "sign": "Gemini", "house": 3, "absolute_lon": 75.0, "degree_within_sign": 15.0, "retrograde": False}, ] def _make_stellium_planets() -> list[dict[str, Any]]: """Create a planet list with a stellium in Leo (sign) and house 5.""" return [ {"body": "sun", "sign": "Leo", "house": 5, "absolute_lon": 130.0, "degree_within_sign": 10.0, "retrograde": False}, {"body": "moon", "sign": "Leo", "house": 5, "absolute_lon": 135.0, "degree_within_sign": 15.0, "retrograde": False}, {"body": "mercury", "sign": "Leo", "house": 5, "absolute_lon": 140.0, "degree_within_sign": 20.0, "retrograde": False}, {"body": "venus", "sign": "Virgo", "house": 6, "absolute_lon": 155.0, "degree_within_sign": 5.0, "retrograde": False}, {"body": "mars", "sign": "Aries", "house": 1, "absolute_lon": 15.0, "degree_within_sign": 15.0, "retrograde": False}, {"body": "jupiter", "sign": "Sagittarius", "house": 9, "absolute_lon": 255.0, "degree_within_sign": 15.0, "retrograde": False}, {"body": "saturn", "sign": "Capricornus", "house": 10, "absolute_lon": 285.0, "degree_within_sign": 15.0, "retrograde": False}, {"body": "uranus", "sign": "Aquarius", "house": 11, "absolute_lon": 315.0, "degree_within_sign": 15.0, "retrograde": False}, {"body": "neptune", "sign": "Pisces", "house": 12, "absolute_lon": 345.0, "degree_within_sign": 15.0, "retrograde": False}, {"body": "pluto", "sign": "Scorpius", "house": 8, "absolute_lon": 225.0, "degree_within_sign": 15.0, "retrograde": False}, {"body": "chiron", "sign": "Aries", "house": 1, "absolute_lon": 12.0, "degree_within_sign": 12.0, "retrograde": False}, {"body": "true_node", "sign": "Gemini", "house": 3, "absolute_lon": 75.0, "degree_within_sign": 15.0, "retrograde": False}, ] # ── Element Balance ────────────────────────────────────────────────── class TestGetElementBalance: def test_basic_counts(self): planets = _make_planets() result = get_element_balance(planets) assert result["total"] == 12 assert "counts" in result assert "percentages" in result def test_all_elements_present(self): planets = _make_planets() result = get_element_balance(planets) # Leo(fire), Cancer(water), Leo(fire), Virgo(earth), Aries(fire), # Sagittarius(fire), Capricornus(earth), Aquarius(air), Pisces(water), # Scorpius(water), Aries(fire), Gemini(air) assert result["counts"]["fire"] == 5 # sun, mercury, mars, jupiter, chiron assert result["counts"]["earth"] == 2 # venus, saturn assert result["counts"]["air"] == 2 # uranus, true_node assert result["counts"]["water"] == 3 # moon, neptune, pluto def test_percentages_sum_to_100(self): planets = _make_planets() result = get_element_balance(planets) pct_sum = sum(result["percentages"].values()) assert abs(pct_sum - 100.0) < 1.0 def test_empty_planets(self): result = get_element_balance([]) assert result["total"] == 0 assert all(v == 0 for v in result["counts"].values()) # ── Modality Balance ───────────────────────────────────────────────── class TestGetModalityBalance: def test_basic_counts(self): planets = _make_planets() result = get_modality_balance(planets) assert result["total"] == 12 assert "counts" in result assert "percentages" in result def test_modalities_present(self): planets = _make_planets() result = get_modality_balance(planets) # Leo(fixed), Cancer(cardinal), Leo(fixed), Virgo(mutable), Aries(cardinal), # Sagittarius(mutable), Capricornus(cardinal), Aquarius(fixed), Pisces(mutable), # Scorpius(fixed), Aries(cardinal), Gemini(mutable) assert result["counts"]["cardinal"] == 4 # moon, mars, saturn, chiron assert result["counts"]["fixed"] == 4 # sun, mercury, uranus, pluto assert result["counts"]["mutable"] == 4 # venus, jupiter, neptune, true_node def test_empty_planets(self): result = get_modality_balance([]) assert result["total"] == 0 # ── Hemisphere Emphasis ────────────────────────────────────────────── class TestGetHemisphereEmphasis: def test_basic_counts(self): planets = _make_planets() result = get_hemisphere_emphasis(planets) assert sum(result.values()) == 24 # 12 planets * 2 (each counted in upper/lower AND east/west) def test_upper_lower_sum(self): planets = _make_planets() result = get_hemisphere_emphasis(planets) assert result["upper"] + result["lower"] == 12 def test_east_west_sum(self): planets = _make_planets() result = get_hemisphere_emphasis(planets) assert result["east"] + result["west"] == 12 # ── Stellium Detection ─────────────────────────────────────────────── class TestDetectStelliums: def test_no_stellium(self): planets = _make_planets() result = detect_stelliums(planets) assert len(result) == 0 def test_sign_stellium(self): planets = _make_stellium_planets() result = detect_stelliums(planets) sign_stelliums = [s for s in result if s["type"] == "sign"] assert len(sign_stelliums) >= 1 leo_stellium = [s for s in sign_stelliums if s["key"] == "Leo"] assert len(leo_stellium) == 1 assert set(leo_stellium[0]["planets"]) == {"sun", "moon", "mercury"} def test_house_stellium(self): planets = _make_stellium_planets() result = detect_stelliums(planets) house_stelliums = [s for s in result if s["type"] == "house"] assert len(house_stelliums) >= 1 h5_stellium = [s for s in house_stelliums if s["key"] == "5"] assert len(h5_stellium) == 1 assert set(h5_stellium[0]["planets"]) == {"sun", "moon", "mercury"} # ── Empty Houses ───────────────────────────────────────────────────── class TestGetEmptyHouses: def test_some_empty(self): planets = _make_planets() result = get_empty_houses(planets) occupied = {p["house"] for p in planets} expected = sorted(h for h in range(1, 13) if h not in occupied) assert result == expected def test_all_occupied(self): # Create planets in all 12 houses planets = [{"body": f"p{i}", "house": i} for i in range(1, 13)] result = get_empty_houses(planets) assert result == [] # ── Chart Ruler ────────────────────────────────────────────────────── class TestGetChartRuler: def test_aries_asc_ruler_is_mars(self): planets = _make_planets() result = get_chart_ruler("Aries", planets) assert result is not None assert result["body"] == "mars" assert result["sign"] == "Aries" def test_leo_asc_ruler_is_sun(self): planets = _make_planets() result = get_chart_ruler("Leo", planets) assert result is not None assert result["body"] == "sun" def test_scorpius_asc_modern(self): planets = _make_planets() result = get_chart_ruler("Scorpius", planets) assert result is not None assert result["body"] == "pluto" def test_scorpius_asc_traditional(self): planets = _make_planets() result = get_chart_ruler("Scorpius", planets, traditional=True) assert result is not None assert result["body"] == "mars" def test_ruler_not_found(self): planets = [{"body": "sun", "sign": "Aries", "house": 1}] result = get_chart_ruler("Aries", planets) assert result is None def test_ruler_has_retrograde(self): planets = _make_planets() result = get_chart_ruler("Sagittarius", planets) assert result is not None assert result["body"] == "jupiter" assert result["retrograde"] is True # ── House Rulers ───────────────────────────────────────────────────── class TestGetHouseRulers: def test_returns_12_entries(self): planets = _make_planets() houses = calculate_houses(6.0, 45.0, "placidus") result = get_house_rulers(houses, planets) assert len(result) == 12 def test_each_has_required_keys(self): planets = _make_planets() houses = calculate_houses(6.0, 45.0, "placidus") result = get_house_rulers(houses, planets) for entry in result: assert "house" in entry assert "cusp_sign" in entry assert "ruler" in entry assert "ruler_sign" in entry assert "ruler_house" in entry assert "ruler_retrograde" in entry # ── Group Planets by House ─────────────────────────────────────────── class TestGroupPlanetsByHouse: def test_grouping(self): planets = _make_planets() result = group_planets_by_house(planets) assert 5 in result # sun, mercury in house 5 assert set(result[5]) == {"sun", "mercury"} def test_all_planets_grouped(self): planets = _make_planets() result = group_planets_by_house(planets) total = sum(len(v) for v in result.values()) assert total == 12 # ── Group Planets by Sign ──────────────────────────────────────────── class TestGroupPlanetsBySign: def test_grouping(self): planets = _make_planets() result = group_planets_by_sign(planets) assert "Leo" in result assert set(result["Leo"]) == {"sun", "mercury"} def test_all_planets_grouped(self): planets = _make_planets() result = group_planets_by_sign(planets) total = sum(len(v) for v in result.values()) assert total == 12 # ── House Type Counts ──────────────────────────────────────────────── class TestGetHouseTypeCounts: def test_counts(self): planets = _make_planets() result = get_house_type_counts(planets) assert result["angular"] + result["succedent"] + result["cadent"] == 12 def test_angular_count(self): planets = _make_planets() result = get_house_type_counts(planets) # mars in h1, chiron in h1, moon in h4, saturn in h10 = 4 angular assert result["angular"] == 4 # ── Retrograde Planets ─────────────────────────────────────────────── class TestGetRetrogradePlanets: def test_retrograde_list(self): planets = _make_planets() result = get_retrograde_planets(planets) names = [p["body"] for p in result] assert "jupiter" in names assert "pluto" in names def test_no_retrogrades(self): planets = [{"body": "sun", "retrograde": False}] result = get_retrograde_planets(planets) assert len(result) == 0 def test_includes_sign_and_house(self): planets = _make_planets() result = get_retrograde_planets(planets) jupiter = [p for p in result if p["body"] == "jupiter"][0] assert jupiter["sign"] == "Sagittarius" assert jupiter["house"] == 9 # ── Nodal Axis ─────────────────────────────────────────────────────── class TestGetNodalAxis: def test_north_node_found(self): planets = _make_planets() result = get_nodal_axis(planets) assert result["north_node"] is not None assert result["north_node"]["sign"] == "Gemini" def test_south_node_is_opposite(self): planets = _make_planets() result = get_nodal_axis(planets) assert result["south_node"] is not None # Gemini opposite = Sagittarius assert result["south_node"]["sign"] == "Sagittarius" def test_with_houses(self): planets = _make_planets() houses = calculate_houses(6.0, 45.0, "placidus") result = get_nodal_axis(planets, houses) assert result["north_node"]["house"] is not None assert result["south_node"]["house"] is not None def test_no_node(self): planets = [{"body": "sun", "sign": "Aries"}] result = get_nodal_axis(planets) assert result["north_node"] is None assert result["south_node"] is None # ── Saturn Info ────────────────────────────────────────────────────── class TestGetSaturnInfo: def test_saturn_found(self): planets = _make_planets() result = get_saturn_info(planets) assert result is not None assert result["body"] == "saturn" assert result["sign"] == "Capricornus" assert result["house"] == 10 def test_saturn_retrograde(self): planets = _make_planets() result = get_saturn_info(planets) assert result["retrograde"] is False def test_no_saturn(self): planets = [{"body": "sun", "sign": "Aries"}] result = get_saturn_info(planets) assert result is None # ── Pluto Polarity Point ───────────────────────────────────────────── class TestGetPlutoPolarityPoint: def test_ppp_is_opposite(self): planets = _make_planets() result = get_pluto_polarity_point(planets) assert result is not None # Pluto at 225° (Scorpius 15°), PPP at 45° (Taurus 15°) assert result["sign"] == "Taurus" def test_ppp_with_houses(self): planets = _make_planets() houses = calculate_houses(6.0, 45.0, "placidus") result = get_pluto_polarity_point(planets, houses) assert result["house"] is not None def test_no_pluto(self): planets = [{"body": "sun", "sign": "Aries"}] result = get_pluto_polarity_point(planets) assert result is None # ── Part of Fortune ────────────────────────────────────────────────── class TestGetPartOfFortune: def test_basic_calculation(self): # ASC at 90°, Sun at 135°, Moon at 105° # PoF = 90 + 105 - 135 = 60° (Gemini) result = get_part_of_fortune(90.0, 135.0, 105.0) assert result["sign"] == "Gemini" def test_wraparound(self): # ASC at 10°, Sun at 350°, Moon at 350° # PoF = 10 + 350 - 350 = 10° (Aries) result = get_part_of_fortune(10.0, 350.0, 350.0) assert result["sign"] == "Aries" def test_with_houses(self): result = get_part_of_fortune(90.0, 135.0, 105.0, houses=calculate_houses(6.0, 45.0, "placidus")) assert result["house"] is not None # ── 12th House Analysis ────────────────────────────────────────────── class TestGetTwelfthHouseAnalysis: def test_cusp_sign(self): planets = _make_planets() houses = calculate_houses(6.0, 45.0, "placidus") result = get_twelfth_house_analysis(houses, planets) assert result["cusp_sign"] is not None def test_planets_in_12th(self): planets = _make_planets() houses = calculate_houses(6.0, 45.0, "placidus") result = get_twelfth_house_analysis(houses, planets) # neptune is in house 12 in our test data names = [p["body"] for p in result["planets"]] assert "neptune" in names def test_ruler(self): planets = _make_planets() houses = calculate_houses(6.0, 45.0, "placidus") result = get_twelfth_house_analysis(houses, planets) assert result["ruler"] is not None assert result["ruler"]["ruler"] is not None # ── Aspect Filtering ───────────────────────────────────────────────── class TestFilterAspectsByPlanets: def test_filter_to_planets(self): aspects = [ {"body1": "sun", "body2": "moon", "aspect": "conjunction", "orb": 2.0}, {"body1": "sun", "body2": "mars", "aspect": "square", "orb": 3.0}, {"body1": "venus", "body2": "mars", "aspect": "trine", "orb": 1.0}, ] result = filter_aspects_by_planets(aspects, {"sun"}) assert len(result) == 2 def test_filter_with_aspect_types(self): aspects = [ {"body1": "sun", "body2": "moon", "aspect": "conjunction", "orb": 2.0}, {"body1": "sun", "body2": "mars", "aspect": "square", "orb": 3.0}, {"body1": "sun", "body2": "jupiter", "aspect": "trine", "orb": 1.0}, ] result = filter_aspects_by_planets(aspects, {"sun"}, {"conjunction", "square"}) assert len(result) == 2 def test_strips_prefixes(self): aspects = [ {"body1": "transit_saturn", "body2": "natal_true_node", "aspect": "conjunction", "orb": 1.0}, {"body1": "p1_saturn", "body2": "p2_sun", "aspect": "opposition", "orb": 2.0}, ] result = filter_aspects_by_planets(aspects, {"saturn"}) assert len(result) == 2 def test_no_match(self): aspects = [ {"body1": "sun", "body2": "moon", "aspect": "conjunction", "orb": 2.0}, ] result = filter_aspects_by_planets(aspects, {"pluto"}) assert len(result) == 0 class TestGetNatalAspectsToPlanets: def test_sorted_by_orb(self): aspects = [ {"body1": "sun", "body2": "true_node", "aspect": "conjunction", "orb": 3.0}, {"body1": "moon", "body2": "true_node", "aspect": "square", "orb": 1.0}, {"body1": "mars", "body2": "true_node", "aspect": "opposition", "orb": 2.0}, ] result = get_natal_aspects_to_planets(aspects, {"true_node"}) orbs = [a["orb"] for a in result] assert orbs == sorted(orbs) def test_filter_to_hard_aspects(self): aspects = [ {"body1": "sun", "body2": "true_node", "aspect": "conjunction", "orb": 1.0}, {"body1": "moon", "body2": "true_node", "aspect": "trine", "orb": 2.0}, ] result = get_natal_aspects_to_planets(aspects, {"true_node"}, {"conjunction", "square", "opposition"}) assert len(result) == 1 assert result[0]["aspect"] == "conjunction" # ── Aspect Pattern Detection ───────────────────────────────────────── class TestDetectAspectPatterns: def test_no_patterns(self): """Random aspects with no patterns.""" planets = _make_planets() aspects = [ {"body1": "sun", "body2": "moon", "aspect": "conjunction", "orb": 2.0}, {"body1": "venus", "body2": "mars", "aspect": "trine", "orb": 3.0}, ] result = detect_aspect_patterns(planets, aspects) assert len(result) == 0 def test_t_square_detection(self): """Sun-Moon opposition, both square Mars = T-square with Mars apex.""" planets = [ {"body": "sun", "sign": "Aries", "house": 1, "absolute_lon": 0.0}, {"body": "moon", "sign": "Libra", "house": 7, "absolute_lon": 180.0}, {"body": "mars", "sign": "Capricornus", "house": 10, "absolute_lon": 270.0}, ] aspects = [ {"body1": "sun", "body2": "moon", "aspect": "opposition", "orb": 0.0}, {"body1": "sun", "body2": "mars", "aspect": "square", "orb": 0.0}, {"body1": "moon", "body2": "mars", "aspect": "square", "orb": 0.0}, ] result = detect_aspect_patterns(planets, aspects) t_squares = [p for p in result if p["type"] == "T-square"] assert len(t_squares) == 1 assert t_squares[0]["apex"] == "mars" assert set(t_squares[0]["planets"]) == {"sun", "moon", "mars"} assert t_squares[0]["modality"] == "cardinal" def test_grand_trine_detection(self): """Three planets in trine, same element.""" planets = [ {"body": "sun", "sign": "Aries", "house": 1, "absolute_lon": 15.0}, {"body": "jupiter", "sign": "Leo", "house": 5, "absolute_lon": 135.0}, {"body": "saturn", "sign": "Sagittarius", "house": 9, "absolute_lon": 255.0}, ] aspects = [ {"body1": "sun", "body2": "jupiter", "aspect": "trine", "orb": 0.0}, {"body1": "jupiter", "body2": "saturn", "aspect": "trine", "orb": 0.0}, {"body1": "sun", "body2": "saturn", "aspect": "trine", "orb": 0.0}, ] result = detect_aspect_patterns(planets, aspects) gt = [p for p in result if p["type"] == "Grand Trine"] assert len(gt) == 1 assert set(gt[0]["planets"]) == {"sun", "jupiter", "saturn"} assert gt[0]["element"] == "fire" def test_yod_detection(self): """Two planets in sextile, both quincunx a third.""" planets = [ {"body": "sun", "sign": "Aries", "house": 1, "absolute_lon": 15.0}, {"body": "jupiter", "sign": "Gemini", "house": 3, "absolute_lon": 75.0}, {"body": "saturn", "sign": "Scorpius", "house": 8, "absolute_lon": 225.0}, ] aspects = [ {"body1": "sun", "body2": "jupiter", "aspect": "sextile", "orb": 0.0}, {"body1": "sun", "body2": "saturn", "aspect": "quincunx", "orb": 0.0}, {"body1": "jupiter", "body2": "saturn", "aspect": "quincunx", "orb": 0.0}, ] result = detect_aspect_patterns(planets, aspects) yods = [p for p in result if p["type"] == "Yod"] assert len(yods) == 1 assert yods[0]["apex"] == "saturn" assert set(yods[0]["planets"]) == {"sun", "jupiter", "saturn"} def test_orb_limit_filters_patterns(self): """Patterns with orbs exceeding the limit should not be detected.""" planets = [ {"body": "sun", "sign": "Aries", "house": 1, "absolute_lon": 0.0}, {"body": "moon", "sign": "Libra", "house": 7, "absolute_lon": 180.0}, {"body": "mars", "sign": "Capricornus", "house": 10, "absolute_lon": 270.0}, ] aspects = [ {"body1": "sun", "body2": "moon", "aspect": "opposition", "orb": 0.0}, {"body1": "sun", "body2": "mars", "aspect": "square", "orb": 10.0}, # too wide {"body1": "moon", "body2": "mars", "aspect": "square", "orb": 10.0}, # too wide ] result = detect_aspect_patterns(planets, aspects, orb_limit=8.0) t_squares = [p for p in result if p["type"] == "T-square"] assert len(t_squares) == 0 # ── Chart Shape Detection ──────────────────────────────────────────── class TestDetectChartShape: def test_bundle(self): """All planets within 120°.""" planets = [ {"body": "sun", "absolute_lon": 10.0}, {"body": "moon", "absolute_lon": 30.0}, {"body": "mars", "absolute_lon": 80.0}, {"body": "venus", "absolute_lon": 100.0}, ] result = detect_chart_shape(planets) assert result["shape"] == "bundle" def test_bowl(self): """All planets within 180°.""" planets = [ {"body": "sun", "absolute_lon": 0.0}, {"body": "moon", "absolute_lon": 45.0}, {"body": "mars", "absolute_lon": 90.0}, {"body": "venus", "absolute_lon": 150.0}, ] result = detect_chart_shape(planets) assert result["shape"] == "bowl" def test_splash(self): """Planets spread around the full chart.""" planets = [ {"body": "sun", "absolute_lon": 0.0}, {"body": "moon", "absolute_lon": 120.0}, {"body": "mars", "absolute_lon": 240.0}, ] result = detect_chart_shape(planets) assert result["shape"] == "splash" def test_single_planet(self): result = detect_chart_shape([{"body": "sun", "absolute_lon": 0.0}]) assert result["shape"] == "unknown" def test_empty(self): result = detect_chart_shape([]) assert result["shape"] == "unknown" def test_largest_gap_reported(self): planets = [ {"body": "sun", "absolute_lon": 0.0}, {"body": "moon", "absolute_lon": 10.0}, {"body": "mars", "absolute_lon": 20.0}, ] result = detect_chart_shape(planets) assert result["largest_gap"] > 0 assert result["occupied_arc"] > 0