test_live_charts.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486
  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. # Note: direct-call tests use UTC datetime (10:37 UTC = 11:30 LMT)
  72. EINSTEIN = {
  73. "name": "Albert Einstein",
  74. "datetime": "1879-03-14T10:37:00", # UTC (11:30 LMT - 53min)
  75. "lat": 48.39841,
  76. "lon": 9.99155,
  77. "planets": {
  78. # body: (sign, degree_in_sign, retrograde)
  79. "sun": ("Pisces", 23.50, False),
  80. "moon": ("Sagittarius", 14.40, False),
  81. "mercury": ("Aries", 3.13, False),
  82. "venus": ("Aries", 16.97, False),
  83. "mars": ("Capricornus", 26.91, False),
  84. "jupiter": ("Aquarius", 27.48, False),
  85. "saturn": ("Aries", 4.19, False),
  86. "uranus": ("Virgo", 1.29, True),
  87. "neptune": ("Taurus", 7.87, False),
  88. "pluto": ("Taurus", 24.73, False),
  89. "chiron": ("Taurus", 5.55, False),
  90. "true_node": ("Aquarius", 2.73, True),
  91. "mean_node": ("Aquarius", 1.48, True),
  92. },
  93. "angles": {
  94. "ascendant": ("Cancer", 8.72),
  95. "midheaven": ("Pisces", 9.07),
  96. },
  97. }
  98. # Chaka Khan: 1953-03-23 9:05 PM CST (UTC-6), Chicago, IL
  99. # Rodden AA
  100. # Note: direct-call tests use UTC datetime (03:05 UTC next day = 21:05 CST)
  101. CHAKA_KHAN = {
  102. "name": "Chaka Khan",
  103. "datetime": "1953-03-24T03:05:00", # UTC (21:05 CST + 6h)
  104. "lat": 41.87811,
  105. "lon": -87.62980,
  106. "planets": {
  107. "sun": ("Aries", 3.19, False),
  108. "moon": ("Cancer", 23.41, False),
  109. "mercury": ("Pisces", 22.83, True),
  110. "venus": ("Taurus", 1.32, True),
  111. "mars": ("Taurus", 2.80, False),
  112. "jupiter": ("Taurus", 19.80, False),
  113. "saturn": ("Libra", 25.53, True),
  114. "uranus": ("Cancer", 14.43, False),
  115. "neptune": ("Libra", 23.05, True),
  116. "pluto": ("Leo", 21.15, True),
  117. "chiron": ("Capricornus", 20.22, False),
  118. "true_node": ("Aquarius", 11.14, False),
  119. "mean_node": ("Aquarius", 9.73, True),
  120. },
  121. "angles": {
  122. "ascendant": ("Scorpius", 8.93),
  123. "midheaven": ("Leo", 17.45),
  124. },
  125. }
  126. # Donald Trump: 1946-06-14 10:54 AM EDT (UTC-4), Queens, NY
  127. # Rodden AA
  128. # Note: direct-call tests use UTC datetime (14:54 UTC = 10:54 EDT)
  129. TRUMP = {
  130. "name": "Donald Trump",
  131. "datetime": "1946-06-14T14:54:00", # UTC (10:54 EDT + 4h)
  132. "lat": 40.72677,
  133. "lon": -73.74152,
  134. "planets": {
  135. "sun": ("Gemini", 22.93, False),
  136. "moon": ("Sagittarius", 21.20, False),
  137. "mercury": ("Cancer", 8.87, False),
  138. "venus": ("Cancer", 25.73, False),
  139. "mars": ("Leo", 26.78, False),
  140. "jupiter": ("Libra", 17.45, True),
  141. "saturn": ("Cancer", 23.82, False),
  142. "uranus": ("Gemini", 17.90, False),
  143. "neptune": ("Libra", 5.85, True),
  144. "pluto": ("Leo", 10.05, False),
  145. "chiron": ("Libra", 14.92, False),
  146. "true_node": ("Gemini", 20.75, False),
  147. "mean_node": ("Gemini", 20.75, True),
  148. },
  149. "angles": {
  150. "ascendant": ("Virgo", 0.03),
  151. "midheaven": ("Taurus", 24.40),
  152. },
  153. }
  154. def _check_planets(result: dict, ref: dict, tol: float = 0.5) -> list[str]:
  155. """Check planetary positions against reference. Returns list of errors."""
  156. errors = []
  157. planets = {p["body"]: p for p in result.get("planets", [])}
  158. for name, (ref_sign, ref_deg, ref_retro) in ref["planets"].items():
  159. if name not in planets:
  160. errors.append(f"{name}: missing from result")
  161. continue
  162. p = planets[name]
  163. ref_abs = SIGN_START[ref_sign] + ref_deg
  164. if p["sign"] != ref_sign:
  165. errors.append(f"{name}: sign {p['sign']} != {ref_sign}")
  166. if abs(p["degree_within_sign"] - ref_deg) > tol:
  167. errors.append(
  168. f"{name}: deg {p['degree_within_sign']:.2f} != {ref_deg}°"
  169. )
  170. if abs(p["absolute_lon"] - ref_abs) > tol:
  171. errors.append(
  172. f"{name}: abs_lon {p['absolute_lon']:.2f} != {ref_abs:.2f}"
  173. )
  174. if p["retrograde"] != ref_retro:
  175. errors.append(
  176. f"{name}: retrograde {p['retrograde']} != {ref_retro}"
  177. )
  178. return errors
  179. def _check_angles(result: dict, ref: dict, tol: float = 0.5) -> list[str]:
  180. """Check angles against reference. Returns list of errors."""
  181. errors = []
  182. angles = result.get("angles", {})
  183. for angle_name, (ref_sign, ref_deg) in ref["angles"].items():
  184. if angle_name not in angles:
  185. errors.append(f"{angle_name}: missing from result")
  186. continue
  187. a = angles[angle_name]
  188. ref_abs = SIGN_START[ref_sign] + ref_deg
  189. if a["sign"] != ref_sign:
  190. errors.append(f"{angle_name}: sign {a['sign']} != {ref_sign}")
  191. if abs(a["absolute_lon"] - ref_abs) > tol:
  192. errors.append(
  193. f"{angle_name}: abs_lon {a['absolute_lon']:.2f} != {ref_abs:.2f}"
  194. )
  195. return errors
  196. SIGN_START = {
  197. "Aries": 0, "Taurus": 30, "Gemini": 60, "Cancer": 90,
  198. "Leo": 120, "Virgo": 150, "Libra": 180, "Scorpius": 210,
  199. "Sagittarius": 240, "Capricornus": 270, "Aquarius": 300, "Pisces": 330,
  200. }
  201. # ── Tests: Einstein ───────────────────────────────────────────────────
  202. class TestLiveEinstein:
  203. """Call live astro-mcp server for Einstein's chart and verify."""
  204. @pytest.mark.asyncio
  205. async def test_natal_chart_planets(self):
  206. result = await call_astro_tool(
  207. "calculate_natal_chart",
  208. {
  209. "birth_datetime": EINSTEIN["datetime"],
  210. "latitude": EINSTEIN["lat"],
  211. "longitude": EINSTEIN["lon"],
  212. "house_system": "placidus",
  213. },
  214. )
  215. assert "error" not in result, f"Server error: {result.get('error')}"
  216. errors = _check_planets(result, EINSTEIN)
  217. assert not errors, "Planet mismatches:\n" + "\n".join(errors)
  218. @pytest.mark.asyncio
  219. async def test_natal_chart_angles(self):
  220. result = await call_astro_tool(
  221. "calculate_natal_chart",
  222. {
  223. "birth_datetime": EINSTEIN["datetime"],
  224. "latitude": EINSTEIN["lat"],
  225. "longitude": EINSTEIN["lon"],
  226. "house_system": "placidus",
  227. },
  228. )
  229. assert "error" not in result
  230. errors = _check_angles(result, EINSTEIN)
  231. assert not errors, "Angle mismatches:\n" + "\n".join(errors)
  232. @pytest.mark.asyncio
  233. async def test_natal_chart_houses(self):
  234. result = await call_astro_tool(
  235. "calculate_natal_chart",
  236. {
  237. "birth_datetime": EINSTEIN["datetime"],
  238. "latitude": EINSTEIN["lat"],
  239. "longitude": EINSTEIN["lon"],
  240. "house_system": "placidus",
  241. },
  242. )
  243. assert "error" not in result
  244. assert "houses" in result
  245. assert len(result["houses"]) == 12
  246. house_numbers = sorted(h["house"] for h in result["houses"])
  247. assert house_numbers == list(range(1, 13))
  248. for h in result["houses"]:
  249. assert "sign" in h
  250. assert "degree" in h
  251. assert "absolute_lon" in h
  252. @pytest.mark.asyncio
  253. async def test_natal_chart_aspects(self):
  254. result = await call_astro_tool(
  255. "calculate_natal_chart",
  256. {
  257. "birth_datetime": EINSTEIN["datetime"],
  258. "latitude": EINSTEIN["lat"],
  259. "longitude": EINSTEIN["lon"],
  260. "house_system": "placidus",
  261. },
  262. )
  263. assert "error" not in result
  264. assert "aspects" in result
  265. assert len(result["aspects"]) > 0
  266. for asp in result["aspects"]:
  267. assert "body1" in asp
  268. assert "body2" in asp
  269. assert "aspect" in asp
  270. assert "orb" in asp
  271. # ── Tests: Chaka Khan ─────────────────────────────────────────────────
  272. class TestLiveChakaKhan:
  273. """Call live astro-mcp server for Chaka Khan's chart and verify."""
  274. @pytest.mark.asyncio
  275. async def test_natal_chart_planets(self):
  276. result = await call_astro_tool(
  277. "calculate_natal_chart",
  278. {
  279. "birth_datetime": CHAKA_KHAN["datetime"],
  280. "latitude": CHAKA_KHAN["lat"],
  281. "longitude": CHAKA_KHAN["lon"],
  282. "house_system": "placidus",
  283. },
  284. )
  285. assert "error" not in result, f"Server error: {result.get('error')}"
  286. errors = _check_planets(result, CHAKA_KHAN)
  287. assert not errors, "Planet mismatches:\n" + "\n".join(errors)
  288. @pytest.mark.asyncio
  289. async def test_natal_chart_angles(self):
  290. result = await call_astro_tool(
  291. "calculate_natal_chart",
  292. {
  293. "birth_datetime": CHAKA_KHAN["datetime"],
  294. "latitude": CHAKA_KHAN["lat"],
  295. "longitude": CHAKA_KHAN["lon"],
  296. "house_system": "placidus",
  297. },
  298. )
  299. assert "error" not in result
  300. errors = _check_angles(result, CHAKA_KHAN)
  301. assert not errors, "Angle mismatches:\n" + "\n".join(errors)
  302. @pytest.mark.asyncio
  303. async def test_retrograde_planets(self):
  304. """Chaka Khan has 6 retrograde planets."""
  305. result = await call_astro_tool(
  306. "calculate_natal_chart",
  307. {
  308. "birth_datetime": CHAKA_KHAN["datetime"],
  309. "latitude": CHAKA_KHAN["lat"],
  310. "longitude": CHAKA_KHAN["lon"],
  311. "house_system": "placidus",
  312. "include_overview": True,
  313. },
  314. )
  315. assert "error" not in result
  316. assert "overview" in result
  317. retro = result["overview"]["retrograde_planets"]
  318. retro_names = {p["body"] for p in retro}
  319. expected = {"mercury", "venus", "saturn", "neptune", "pluto", "mean_node"}
  320. assert retro_names == expected, \
  321. f"Retrograde mismatch: expected {expected}, got {retro_names}"
  322. # ── Tests: Donald Trump ───────────────────────────────────────────────
  323. class TestLiveTrump:
  324. """Call live astro-mcp server for Donald Trump's chart and verify."""
  325. @pytest.mark.asyncio
  326. async def test_natal_chart_planets(self):
  327. result = await call_astro_tool(
  328. "calculate_natal_chart",
  329. {
  330. "birth_datetime": TRUMP["datetime"],
  331. "latitude": TRUMP["lat"],
  332. "longitude": TRUMP["lon"],
  333. "house_system": "placidus",
  334. },
  335. )
  336. assert "error" not in result, f"Server error: {result.get('error')}"
  337. errors = _check_planets(result, TRUMP)
  338. assert not errors, "Planet mismatches:\n" + "\n".join(errors)
  339. @pytest.mark.asyncio
  340. async def test_natal_chart_angles(self):
  341. result = await call_astro_tool(
  342. "calculate_natal_chart",
  343. {
  344. "birth_datetime": TRUMP["datetime"],
  345. "latitude": TRUMP["lat"],
  346. "longitude": TRUMP["lon"],
  347. "house_system": "placidus",
  348. },
  349. )
  350. assert "error" not in result
  351. errors = _check_angles(result, TRUMP)
  352. assert not errors, "Angle mismatches:\n" + "\n".join(errors)
  353. @pytest.mark.asyncio
  354. async def test_natal_chart_houses(self):
  355. result = await call_astro_tool(
  356. "calculate_natal_chart",
  357. {
  358. "birth_datetime": TRUMP["datetime"],
  359. "latitude": TRUMP["lat"],
  360. "longitude": TRUMP["lon"],
  361. "house_system": "placidus",
  362. },
  363. )
  364. assert "error" not in result
  365. assert "houses" in result
  366. assert len(result["houses"]) == 12
  367. house_numbers = sorted(h["house"] for h in result["houses"])
  368. assert house_numbers == list(range(1, 13))
  369. for h in result["houses"]:
  370. assert "sign" in h
  371. assert "degree" in h
  372. assert "absolute_lon" in h
  373. @pytest.mark.asyncio
  374. async def test_natal_chart_aspects(self):
  375. result = await call_astro_tool(
  376. "calculate_natal_chart",
  377. {
  378. "birth_datetime": TRUMP["datetime"],
  379. "latitude": TRUMP["lat"],
  380. "longitude": TRUMP["lon"],
  381. "house_system": "placidus",
  382. },
  383. )
  384. assert "error" not in result
  385. assert "aspects" in result
  386. assert len(result["aspects"]) > 0
  387. for asp in result["aspects"]:
  388. assert "body1" in asp
  389. assert "body2" in asp
  390. assert "aspect" in asp
  391. assert "orb" in asp
  392. # ── Tests: _byId tools with DB celebrities ────────────────────────────
  393. class TestLiveByNickname:
  394. """Test _byId tools using celebrities in the persons DB."""
  395. @pytest.mark.asyncio
  396. async def test_einstein_by_nickname(self):
  397. result = await call_astro_tool(
  398. "calculate_natal_chart_by_id",
  399. {"person_id": "einstein", "house_system": "placidus"},
  400. )
  401. assert "error" not in result, f"Server error: {result.get('error')}"
  402. assert result.get("chart_type") == "natal"
  403. assert "planets" in result
  404. assert "houses" in result
  405. assert "angles" in result
  406. errors = _check_planets(result, EINSTEIN)
  407. assert not errors, "Planet mismatches:\n" + "\n".join(errors)
  408. @pytest.mark.asyncio
  409. async def test_chaka_by_nickname(self):
  410. result = await call_astro_tool(
  411. "calculate_natal_chart_by_id",
  412. {"person_id": "chaka", "house_system": "placidus"},
  413. )
  414. assert "error" not in result
  415. assert result.get("chart_type") == "natal"
  416. errors = _check_planets(result, CHAKA_KHAN)
  417. assert not errors, "Planet mismatches:\n" + "\n".join(errors)
  418. @pytest.mark.asyncio
  419. async def test_trump_by_nickname(self):
  420. result = await call_astro_tool(
  421. "calculate_natal_chart_by_id",
  422. {"person_id": "trump", "house_system": "placidus"},
  423. )
  424. assert "error" not in result
  425. assert result.get("chart_type") == "natal"
  426. errors = _check_planets(result, TRUMP)
  427. assert not errors, "Planet mismatches:\n" + "\n".join(errors)