|
@@ -1,1161 +0,0 @@
|
|
|
-"""
|
|
|
|
|
-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
|
|
|