test_live_charts.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. """
  2. End-to-end integration tests: call the LIVE astro-mcp server via MCP SSE
  3. and compare results against reference data.
  4. Reference values were obtained by querying the same Swiss Ephemeris that
  5. the ephemeris-mcp server uses, so these tests verify end-to-end correctness
  6. of the full pipeline: MCP client -> astro-mcp -> ephemeris-mcp -> Swiss Ephemeris.
  7. These tests require both servers to be running:
  8. - ephemeris-mcp (port 7015)
  9. - astro-mcp (port 7016)
  10. Run with: pytest tests/test_live_charts.py -v
  11. Skip with: SKIP_LIVE_TESTS=1 pytest tests/
  12. """
  13. from __future__ import annotations
  14. import asyncio
  15. import os
  16. from datetime import timedelta
  17. from typing import Any
  18. import pytest
  19. from mcp import ClientSession
  20. from mcp.client.sse import sse_client
  21. pytestmark = [
  22. pytest.mark.skipif(
  23. os.environ.get("SKIP_LIVE_TESTS", "0") == "1",
  24. reason="SKIP_LIVE_TESTS=1",
  25. ),
  26. pytest.mark.live,
  27. ]
  28. ASTRO_MCP_URL = os.environ.get(
  29. "ASTRO_MCP_URL", "http://192.168.0.249:7016/mcp/sse"
  30. )
  31. async def call_astro_tool(
  32. tool_name: str, arguments: dict[str, Any], timeout: float = 30.0
  33. ) -> dict[str, Any]:
  34. """Call a tool on the live astro-mcp server via MCP SSE."""
  35. url = ASTRO_MCP_URL
  36. if not url.endswith("/mcp/sse"):
  37. url = url.rstrip("/") + "/mcp/sse"
  38. async with sse_client(url, timeout=timeout, sse_read_timeout=timeout) as streams:
  39. async with ClientSession(
  40. *streams, read_timeout_seconds=timedelta(seconds=timeout)
  41. ) as session:
  42. await session.initialize()
  43. result = await session.call_tool(tool_name, arguments)
  44. payload = getattr(result, "structuredContent", None)
  45. if isinstance(payload, dict):
  46. return payload
  47. content_items = getattr(result, "content", []) or []
  48. for item in content_items:
  49. text = getattr(item, "text", None)
  50. if isinstance(text, str) and text.strip():
  51. import json
  52. try:
  53. decoded = json.loads(text)
  54. except Exception:
  55. continue
  56. if isinstance(decoded, dict):
  57. return decoded
  58. error_texts = []
  59. for item in content_items:
  60. text = getattr(item, "text", None)
  61. if isinstance(text, str) and text.strip():
  62. error_texts.append(text.strip())
  63. if error_texts:
  64. return {"error": error_texts[0]}
  65. return {}
  66. # ── Reference data ────────────────────────────────────────────────────
  67. # Values captured from the live server (Swiss Ephemeris via ephemeris-mcp).
  68. # These are the ground truth for our specific ephemeris version.
  69. # Albert Einstein: 1879-03-14 11:30 AM LMT (UTC+0:53), Ulm, Germany
  70. # Rodden AA (birth certificate)
  71. EINSTEIN = {
  72. "name": "Albert Einstein",
  73. "datetime": "1879-03-14T11:30:00+00:53",
  74. "lat": 48.39841,
  75. "lon": 9.99155,
  76. "planets": {
  77. # body: (sign, degree_in_sign, retrograde)
  78. "sun": ("Pisces", 23.50, False),
  79. "moon": ("Sagittarius", 14.40, False),
  80. "mercury": ("Aries", 3.13, False),
  81. "venus": ("Aries", 16.97, False),
  82. "mars": ("Capricornus", 26.91, False),
  83. "jupiter": ("Aquarius", 27.48, False),
  84. "saturn": ("Aries", 4.19, False),
  85. "uranus": ("Virgo", 1.29, True),
  86. "neptune": ("Taurus", 7.87, False),
  87. "pluto": ("Taurus", 24.73, False),
  88. "chiron": ("Aries", 0.00, False),
  89. "true_node": ("Aquarius", 2.73, True),
  90. "mean_node": ("Aquarius", 1.48, True),
  91. },
  92. "angles": {
  93. "ascendant": ("Cancer", 8.90),
  94. "midheaven": ("Pisces", 12.39),
  95. },
  96. }
  97. # Chaka Khan: 1953-03-23 9:05 PM CST (UTC-6), Chicago, IL
  98. # Rodden AA
  99. CHAKA_KHAN = {
  100. "name": "Chaka Khan",
  101. "datetime": "1953-03-23T21:05:00-06:00",
  102. "lat": 41.87811,
  103. "lon": -87.62980,
  104. "planets": {
  105. "sun": ("Aries", 3.19, False),
  106. "moon": ("Cancer", 23.41, False),
  107. "mercury": ("Pisces", 22.83, True),
  108. "venus": ("Taurus", 1.32, True),
  109. "mars": ("Taurus", 2.80, False),
  110. "jupiter": ("Taurus", 19.80, False),
  111. "saturn": ("Libra", 25.53, True),
  112. "uranus": ("Cancer", 14.43, False),
  113. "neptune": ("Libra", 23.05, True),
  114. "pluto": ("Leo", 21.15, True),
  115. "chiron": ("Aries", 0.00, False),
  116. "true_node": ("Aquarius", 11.14, False),
  117. "mean_node": ("Aquarius", 9.73, True),
  118. },
  119. "angles": {
  120. "ascendant": ("Scorpius", 8.93),
  121. "midheaven": ("Leo", 22.33),
  122. },
  123. }
  124. def _check_planets(result: dict, ref: dict, tol: float = 0.5) -> list[str]:
  125. """Check planetary positions against reference. Returns list of errors."""
  126. errors = []
  127. planets = {p["body"]: p for p in result.get("planets", [])}
  128. for name, (ref_sign, ref_deg, ref_retro) in ref["planets"].items():
  129. if name not in planets:
  130. errors.append(f"{name}: missing from result")
  131. continue
  132. p = planets[name]
  133. ref_abs = SIGN_START[ref_sign] + ref_deg
  134. if p["sign"] != ref_sign:
  135. errors.append(f"{name}: sign {p['sign']} != {ref_sign}")
  136. if abs(p["degree_within_sign"] - ref_deg) > tol:
  137. errors.append(
  138. f"{name}: deg {p['degree_within_sign']:.2f} != {ref_deg}°"
  139. )
  140. if abs(p["absolute_lon"] - ref_abs) > tol:
  141. errors.append(
  142. f"{name}: abs_lon {p['absolute_lon']:.2f} != {ref_abs:.2f}"
  143. )
  144. if p["retrograde"] != ref_retro:
  145. errors.append(
  146. f"{name}: retrograde {p['retrograde']} != {ref_retro}"
  147. )
  148. return errors
  149. def _check_angles(result: dict, ref: dict, tol: float = 0.5) -> list[str]:
  150. """Check angles against reference. Returns list of errors."""
  151. errors = []
  152. angles = result.get("angles", {})
  153. for angle_name, (ref_sign, ref_deg) in ref["angles"].items():
  154. if angle_name not in angles:
  155. errors.append(f"{angle_name}: missing from result")
  156. continue
  157. a = angles[angle_name]
  158. ref_abs = SIGN_START[ref_sign] + ref_deg
  159. if a["sign"] != ref_sign:
  160. errors.append(f"{angle_name}: sign {a['sign']} != {ref_sign}")
  161. if abs(a["absolute_lon"] - ref_abs) > tol:
  162. errors.append(
  163. f"{angle_name}: abs_lon {a['absolute_lon']:.2f} != {ref_abs:.2f}"
  164. )
  165. return errors
  166. SIGN_START = {
  167. "Aries": 0, "Taurus": 30, "Gemini": 60, "Cancer": 90,
  168. "Leo": 120, "Virgo": 150, "Libra": 180, "Scorpius": 210,
  169. "Sagittarius": 240, "Capricornus": 270, "Aquarius": 300, "Pisces": 330,
  170. }
  171. # ── Tests: Einstein ───────────────────────────────────────────────────
  172. class TestLiveEinstein:
  173. """Call live astro-mcp server for Einstein's chart and verify."""
  174. @pytest.mark.asyncio
  175. async def test_natal_chart_planets(self):
  176. result = await call_astro_tool(
  177. "calculate_natal_chart",
  178. {
  179. "birth_datetime": EINSTEIN["datetime"],
  180. "latitude": EINSTEIN["lat"],
  181. "longitude": EINSTEIN["lon"],
  182. "house_system": "placidus",
  183. },
  184. )
  185. assert "error" not in result, f"Server error: {result.get('error')}"
  186. errors = _check_planets(result, EINSTEIN)
  187. assert not errors, "Planet mismatches:\n" + "\n".join(errors)
  188. @pytest.mark.asyncio
  189. async def test_natal_chart_angles(self):
  190. result = await call_astro_tool(
  191. "calculate_natal_chart",
  192. {
  193. "birth_datetime": EINSTEIN["datetime"],
  194. "latitude": EINSTEIN["lat"],
  195. "longitude": EINSTEIN["lon"],
  196. "house_system": "placidus",
  197. },
  198. )
  199. assert "error" not in result
  200. errors = _check_angles(result, EINSTEIN)
  201. assert not errors, "Angle mismatches:\n" + "\n".join(errors)
  202. @pytest.mark.asyncio
  203. async def test_natal_chart_houses(self):
  204. result = await call_astro_tool(
  205. "calculate_natal_chart",
  206. {
  207. "birth_datetime": EINSTEIN["datetime"],
  208. "latitude": EINSTEIN["lat"],
  209. "longitude": EINSTEIN["lon"],
  210. "house_system": "placidus",
  211. },
  212. )
  213. assert "error" not in result
  214. assert "houses" in result
  215. assert len(result["houses"]) == 12
  216. house_numbers = sorted(h["house"] for h in result["houses"])
  217. assert house_numbers == list(range(1, 13))
  218. for h in result["houses"]:
  219. assert "sign" in h
  220. assert "degree" in h
  221. assert "absolute_lon" in h
  222. @pytest.mark.asyncio
  223. async def test_natal_chart_aspects(self):
  224. result = await call_astro_tool(
  225. "calculate_natal_chart",
  226. {
  227. "birth_datetime": EINSTEIN["datetime"],
  228. "latitude": EINSTEIN["lat"],
  229. "longitude": EINSTEIN["lon"],
  230. "house_system": "placidus",
  231. },
  232. )
  233. assert "error" not in result
  234. assert "aspects" in result
  235. assert len(result["aspects"]) > 0
  236. for asp in result["aspects"]:
  237. assert "body1" in asp
  238. assert "body2" in asp
  239. assert "aspect" in asp
  240. assert "orb" in asp
  241. # ── Tests: Chaka Khan ─────────────────────────────────────────────────
  242. class TestLiveChakaKhan:
  243. """Call live astro-mcp server for Chaka Khan's chart and verify."""
  244. @pytest.mark.asyncio
  245. async def test_natal_chart_planets(self):
  246. result = await call_astro_tool(
  247. "calculate_natal_chart",
  248. {
  249. "birth_datetime": CHAKA_KHAN["datetime"],
  250. "latitude": CHAKA_KHAN["lat"],
  251. "longitude": CHAKA_KHAN["lon"],
  252. "house_system": "placidus",
  253. },
  254. )
  255. assert "error" not in result, f"Server error: {result.get('error')}"
  256. errors = _check_planets(result, CHAKA_KHAN)
  257. assert not errors, "Planet mismatches:\n" + "\n".join(errors)
  258. @pytest.mark.asyncio
  259. async def test_natal_chart_angles(self):
  260. result = await call_astro_tool(
  261. "calculate_natal_chart",
  262. {
  263. "birth_datetime": CHAKA_KHAN["datetime"],
  264. "latitude": CHAKA_KHAN["lat"],
  265. "longitude": CHAKA_KHAN["lon"],
  266. "house_system": "placidus",
  267. },
  268. )
  269. assert "error" not in result
  270. errors = _check_angles(result, CHAKA_KHAN)
  271. assert not errors, "Angle mismatches:\n" + "\n".join(errors)
  272. @pytest.mark.asyncio
  273. async def test_retrograde_planets(self):
  274. """Chaka Khan has 6 retrograde planets."""
  275. result = await call_astro_tool(
  276. "calculate_natal_chart",
  277. {
  278. "birth_datetime": CHAKA_KHAN["datetime"],
  279. "latitude": CHAKA_KHAN["lat"],
  280. "longitude": CHAKA_KHAN["lon"],
  281. "house_system": "placidus",
  282. "include_overview": True,
  283. },
  284. )
  285. assert "error" not in result
  286. assert "overview" in result
  287. retro = result["overview"]["retrograde_planets"]
  288. retro_names = {p["body"] for p in retro}
  289. expected = {"mercury", "venus", "saturn", "neptune", "pluto", "mean_node"}
  290. assert retro_names == expected, \
  291. f"Retrograde mismatch: expected {expected}, got {retro_names}"
  292. # ── Tests: _byId tools with DB celebrities ────────────────────────────
  293. class TestLiveByNickname:
  294. """Test _byId tools using celebrities in the persons DB."""
  295. @pytest.mark.asyncio
  296. async def test_einstein_by_nickname(self):
  297. result = await call_astro_tool(
  298. "calculate_natal_chart_by_id",
  299. {"person_id": "einstein", "house_system": "placidus"},
  300. )
  301. assert "error" not in result, f"Server error: {result.get('error')}"
  302. assert result.get("chart_type") == "natal"
  303. assert "planets" in result
  304. assert "houses" in result
  305. assert "angles" in result
  306. errors = _check_planets(result, EINSTEIN)
  307. assert not errors, "Planet mismatches:\n" + "\n".join(errors)
  308. @pytest.mark.asyncio
  309. async def test_chaka_by_nickname(self):
  310. result = await call_astro_tool(
  311. "calculate_natal_chart_by_id",
  312. {"person_id": "chaka", "house_system": "placidus"},
  313. )
  314. assert "error" not in result
  315. assert result.get("chart_type") == "natal"
  316. errors = _check_planets(result, CHAKA_KHAN)
  317. assert not errors, "Planet mismatches:\n" + "\n".join(errors)