|
|
@@ -13,20 +13,54 @@ Tests cover:
|
|
|
|
|
|
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,
|
|
|
@@ -441,3 +475,687 @@ class TestSupportedHouseSystems:
|
|
|
|
|
|
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
|