""" Integration tests against well-known celebrity birth charts. Uses mocked call_sky_state to verify that astro-mcp produces correct planetary positions, houses, and angles for charts that can be independently verified against published sources. Reference data source: astro-charts.com (which uses Swiss Ephemeris) """ from __future__ import annotations import asyncio from unittest.mock import AsyncMock, patch import pytest from src.astro_mcp import tools # ── Sign lookup ─────────────────────────────────────────────────────── SIGN_START = { "Aries": 0, "Taurus": 30, "Gemini": 60, "Cancer": 90, "Leo": 120, "Virgo": 150, "Libra": 180, "Scorpius": 210, "Sagittarius": 240, "Capricornus": 270, "Aquarius": 300, "Pisces": 330, } SIGN_NAMES = list(SIGN_START.keys()) def sign_deg_to_abs(sign: str, deg: float) -> float: return SIGN_START[sign] + deg # ── Reference data from astro-charts.com ────────────────────────────── # Albert Einstein: 1879-03-14 11:30 AM LMT, Ulm, Germany # Source: astro-charts.com, Rodden AA (birth certificate) EINSTEIN = { "name": "Albert Einstein", "datetime": "1879-03-14T11:30:00+00:53", "lat": 48.39841, "lon": 9.99155, "planets": { "sun": ("Pisces", 23.50, False), "moon": ("Sagittarius", 14.38, False), "mercury": ("Aries", 3.12, False), "venus": ("Aries", 16.97, False), "mars": ("Capricornus", 26.90, False), "jupiter": ("Aquarius", 27.48, False), "saturn": ("Aries", 4.18, False), "uranus": ("Virgo", 1.28, True), "neptune": ("Taurus", 7.87, False), "pluto": ("Taurus", 24.73, False), "true_node": ("Aquarius", 1.48, True), "chiron": ("Taurus", 5.55, False), }, "angles": { "ascendant": ("Cancer", 8.72), "midheaven": ("Pisces", 9.07), }, } # Chaka Khan: 1953-03-23 9:05 PM CST, Chicago, IL # Source: astro-charts.com, Rodden AA CHAKA_KHAN = { "name": "Chaka Khan", "datetime": "1953-03-23T21:05:00-06:00", "lat": 41.87811, "lon": -87.62980, "planets": { "sun": ("Aries", 3.18, False), "moon": ("Cancer", 23.42, False), "mercury": ("Pisces", 22.83, True), "venus": ("Taurus", 1.32, True), "mars": ("Taurus", 2.80, False), "jupiter": ("Taurus", 19.80, False), "saturn": ("Libra", 25.53, True), "uranus": ("Cancer", 14.43, False), "neptune": ("Libra", 23.05, True), "pluto": ("Leo", 21.15, True), "true_node": ("Aquarius", 9.73, True), "chiron": ("Capricornus", 20.22, False), }, "angles": { "ascendant": ("Scorpius", 8.92), "midheaven": ("Leo", 17.45), }, } # ── Helper: build mock sky_state from reference data ────────────────── def _make_mock_sky_state(ref: dict) -> dict: """Build a mock get_sky_state response matching the ephemeris server format.""" bodies = [] for body_name, (sign, deg, retro) in ref["planets"].items(): abs_lon = sign_deg_to_abs(sign, deg) speed = -0.5 if retro else 0.5 bodies.append({ "body": body_name, "ecliptic_lon": abs_lon, "ecliptic_lat": 0.0, "distance_au": 1.0, "speed_lon": speed, "speed_lat": 0.0, "speed_dist": 0.0, "ra": 0.0, "dec": 0.0, }) asc_sign, asc_deg = ref["angles"]["ascendant"] mc_sign, mc_deg = ref["angles"]["midheaven"] asc_lon = sign_deg_to_abs(asc_sign, asc_deg) mc_lon = sign_deg_to_abs(mc_sign, mc_deg) cusps = [] for i in range(12): cusp_lon = (asc_lon + i * 30) % 360 sign_idx = int(cusp_lon // 30) sign_name = SIGN_NAMES[sign_idx] cusps.append({ "house": i + 1, "absolute_lon": cusp_lon, "sign": sign_name, "abbreviation": sign_name[:3], "degree": cusp_lon % 30.0, }) return { "input": { "datetime": ref["datetime"], "lat": ref["lat"], "lon": ref["lon"], "elevation": 0.0, "geocentric": True, "house_system": "placidus", }, "timestamp_utc": ref["datetime"], "julian_day": 2400000.0, "planetary_positions": {"bodies": bodies}, "lunar_state": {"phase_name": "Waxing Crescent", "illumination_fraction": 0.5}, "sidereal_time": { "greenwich_sidereal_time": 12.0, "local_sidereal_time": 12.0, "obliquity_of_ecliptic": 23.4367, }, "houses": { "system": "PLACIDUS", "cusps": cusps, "ascendant": asc_lon, "midheaven": mc_lon, "descendant": (asc_lon + 180) % 360, "imum_coeli": (mc_lon + 180) % 360, }, } # ── Tests: Albert Einstein ──────────────────────────────────────────── class TestEinsteinChart: """Verify Einstein's chart against astro-charts.com reference data.""" @pytest.fixture def einstein_sky(self): return _make_mock_sky_state(EINSTEIN) async def test_planetary_positions(self, einstein_sky): """Verify planet signs and approximate degrees match reference.""" with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock: mock.return_value = einstein_sky result = await tools.calculate_natal_chart( birth_datetime=EINSTEIN["datetime"], latitude=EINSTEIN["lat"], longitude=EINSTEIN["lon"], house_system="placidus", ) assert "error" not in result assert "planets" in result for planet in result["planets"]: name = planet["body"] if name not in EINSTEIN["planets"]: continue ref_sign, ref_deg, ref_retro = EINSTEIN["planets"][name] ref_abs = sign_deg_to_abs(ref_sign, ref_deg) assert planet["sign"] == ref_sign, \ f"{name}: expected {ref_sign}, got {planet['sign']}" assert abs(planet["degree_within_sign"] - ref_deg) < 1.0, \ f"{name}: expected {ref_deg}°, got {planet['degree_within_sign']:.2f}°" assert abs(planet["absolute_lon"] - ref_abs) < 1.0, \ f"{name}: expected abs_lon {ref_abs:.2f}°, got {planet['absolute_lon']:.2f}°" assert planet["retrograde"] == ref_retro, \ f"{name}: expected retrograde={ref_retro}, got {planet['retrograde']}" async def test_angles(self, einstein_sky): """Verify ASC and MC match reference.""" with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock: mock.return_value = einstein_sky result = await tools.calculate_natal_chart( birth_datetime=EINSTEIN["datetime"], latitude=EINSTEIN["lat"], longitude=EINSTEIN["lon"], house_system="placidus", ) angles = result["angles"] for angle_name, (ref_sign, ref_deg) in EINSTEIN["angles"].items(): ref_abs = sign_deg_to_abs(ref_sign, ref_deg) actual = angles[angle_name] assert actual["sign"] == ref_sign, \ f"{angle_name}: expected {ref_sign}, got {actual['sign']}" assert abs(actual["absolute_lon"] - ref_abs) < 1.0, \ f"{angle_name}: expected {ref_abs:.2f}°, got {actual['absolute_lon']:.2f}°" async def test_houses_present(self, einstein_sky): """Verify all 12 houses are returned with correct structure.""" with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock: mock.return_value = einstein_sky result = await tools.calculate_natal_chart( birth_datetime=EINSTEIN["datetime"], latitude=EINSTEIN["lat"], longitude=EINSTEIN["lon"], house_system="placidus", ) assert "houses" in result assert len(result["houses"]) == 12 for h in result["houses"]: assert "house" in h assert "sign" in h assert "degree" in h assert "absolute_lon" in h assert 1 <= h["house"] <= 12 async def test_aspects_present(self, einstein_sky): """Verify aspects are computed.""" with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock: mock.return_value = einstein_sky result = await tools.calculate_natal_chart( birth_datetime=EINSTEIN["datetime"], latitude=EINSTEIN["lat"], longitude=EINSTEIN["lon"], house_system="placidus", ) assert "aspects" in result assert len(result["aspects"]) > 0 for asp in result["aspects"]: assert "body1" in asp assert "body2" in asp assert "aspect" in asp assert "orb" in asp # ── Tests: Chaka Khan ───────────────────────────────────────────────── class TestChakaKhanChart: """Verify Chaka Khan's chart against astro-charts.com reference data.""" @pytest.fixture def chaka_sky(self): return _make_mock_sky_state(CHAKA_KHAN) async def test_planetary_positions(self, chaka_sky): """Verify planet signs and approximate degrees match reference.""" with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock: mock.return_value = chaka_sky result = await tools.calculate_natal_chart( birth_datetime=CHAKA_KHAN["datetime"], latitude=CHAKA_KHAN["lat"], longitude=CHAKA_KHAN["lon"], house_system="placidus", ) assert "error" not in result for planet in result["planets"]: name = planet["body"] if name not in CHAKA_KHAN["planets"]: continue ref_sign, ref_deg, ref_retro = CHAKA_KHAN["planets"][name] ref_abs = sign_deg_to_abs(ref_sign, ref_deg) assert planet["sign"] == ref_sign, \ f"{name}: expected {ref_sign}, got {planet['sign']}" assert abs(planet["degree_within_sign"] - ref_deg) < 1.0, \ f"{name}: expected {ref_deg}°, got {planet['degree_within_sign']:.2f}°" assert abs(planet["absolute_lon"] - ref_abs) < 1.0, \ f"{name}: expected abs_lon {ref_abs:.2f}°, got {planet['absolute_lon']:.2f}°" assert planet["retrograde"] == ref_retro, \ f"{name}: expected retrograde={ref_retro}, got {planet['retrograde']}" async def test_angles(self, chaka_sky): """Verify ASC and MC match reference.""" with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock: mock.return_value = chaka_sky result = await tools.calculate_natal_chart( birth_datetime=CHAKA_KHAN["datetime"], latitude=CHAKA_KHAN["lat"], longitude=CHAKA_KHAN["lon"], house_system="placidus", ) angles = result["angles"] for angle_name, (ref_sign, ref_deg) in CHAKA_KHAN["angles"].items(): ref_abs = sign_deg_to_abs(ref_sign, ref_deg) actual = angles[angle_name] assert actual["sign"] == ref_sign, \ f"{angle_name}: expected {ref_sign}, got {actual['sign']}" assert abs(actual["absolute_lon"] - ref_abs) < 1.0, \ f"{angle_name}: expected {ref_abs:.2f}°, got {actual['absolute_lon']:.2f}°" async def test_retrograde_planets(self, chaka_sky): """Chaka Khan has 6 retrograde planets — verify they're detected.""" with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock: mock.return_value = chaka_sky result = await tools.calculate_natal_chart( birth_datetime=CHAKA_KHAN["datetime"], latitude=CHAKA_KHAN["lat"], longitude=CHAKA_KHAN["lon"], house_system="placidus", include_overview=True, ) retro = result["overview"]["retrograde_planets"] retro_names = [p["body"] for p in retro] expected_retro = {"mercury", "venus", "saturn", "neptune", "pluto", "true_node"} assert set(retro_names) == expected_retro, \ f"Expected retrograde: {expected_retro}, got: {set(retro_names)}" # ── Tests: extract_houses and extract_angles ────────────────────────── class TestExtractHouses: """Test the extract_houses helper with server-formatted data.""" def test_extracts_12_cusps(self): from src.astro_mcp.ephemeris_client import extract_houses sky = { "houses": { "system": "PLACIDUS", "cusps": [ {"house": i+1, "absolute_lon": float(i*30), "sign": "Aries", "abbreviation": "Ar", "degree": 0.0} for i in range(12) ], "ascendant": 0.0, "midheaven": 90.0, } } result = extract_houses(sky) assert result is not None assert len(result) == 12 def test_returns_none_when_no_houses(self): from src.astro_mcp.ephemeris_client import extract_houses assert extract_houses({}) is None assert extract_houses({"houses": None}) is None def test_returns_none_on_error(self): from src.astro_mcp.ephemeris_client import extract_houses sky = {"houses": {"system": "X", "error": "invalid house system"}} assert extract_houses(sky) is None class TestExtractAngles: """Test the extract_angles helper with server-formatted data.""" def test_extracts_all_four_angles(self): from src.astro_mcp.ephemeris_client import extract_angles sky = { "houses": { "cusps": [], "ascendant": 98.72, "midheaven": 339.07, "descendant": 278.72, "imum_coeli": 159.07, } } result = extract_angles(sky) assert result is not None assert "ascendant" in result assert "midheaven" in result assert "descendant" in result assert "imum_coeli" in result assert result["ascendant"]["sign"] == "Cancer" assert result["midheaven"]["sign"] == "Pisces" assert result["descendant"]["sign"] == "Capricornus" assert result["imum_coeli"]["sign"] == "Virgo" def test_returns_none_when_no_houses(self): from src.astro_mcp.ephemeris_client import extract_angles assert extract_angles({}) is None assert extract_angles({"houses": None}) is None def test_returns_none_on_error(self): from src.astro_mcp.ephemeris_client import extract_angles sky = {"houses": {"error": "failed"}} assert extract_angles(sky) is None def test_computes_dsc_ic_when_missing(self): from src.astro_mcp.ephemeris_client import extract_angles sky = { "houses": { "cusps": [], "ascendant": 90.0, "midheaven": 180.0, } } result = extract_angles(sky) assert result is not None assert result["descendant"]["sign"] == "Capricornus" assert result["imum_coeli"]["sign"] == "Aries"