| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418 |
- """
- 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"
|