PySwiss: - calculate_aspects() in calc.py (conjunction/sextile/square/trine/opposition with orbs) - /api/tz/ endpoint (timezonefinder lat/lon → IANA timezone) - aspects included in /api/chart/ response - timezonefinder==8.2.2 added to requirements - 14 new unit tests (test_calc.py) + 12 new integration tests (TimezoneApiTest, aspect fields) Main app: - Sign, Planet, AspectType, HouseLabel reference models + seeded migrations (0032–0033) - Character model with birth_dt/lat/lon/place, house_system, chart_data, celtic_cross, confirmed_at/retired_at lifecycle (migration 0034) - natus_preview proxy view: calls PySwiss /api/chart/ + optional /api/tz/ auto-resolution, computes planet-in-house distinctions, returns enriched JSON - natus_save view: find-or-create draft Character, confirmed_at on action='confirm' - natus-wheel.js: D3 v7 SVG natal wheel (elements pie, signs, houses, planets, aspects, ASC/MC axes); NatusWheel.draw() / redraw() / clear() - _natus_overlay.html: Nominatim place autocomplete (debounced 400ms), geolocation button with reverse-geocode city name, live chart preview (debounced 300ms), tz auto-fill, NVM / SAVE SKY footer; html.natus-open class toggle pattern - _natus.scss: Gaussian backdrop+modal, two-column form|wheel layout, suggestion dropdown, portrait collapse at 600px, landscape sidebar z-index sink - room.html: include overlay when table_status == SKY_SELECT Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
149 lines
6.0 KiB
Python
149 lines
6.0 KiB
Python
"""
|
|
Unit tests for calc.py helper functions.
|
|
|
|
These tests verify pure calculation logic without hitting the database
|
|
or the Swiss Ephemeris — all inputs are fixed synthetic data.
|
|
|
|
Run:
|
|
pyswiss/.venv/Scripts/python pyswiss/manage.py test pyswiss/apps/charts
|
|
"""
|
|
from django.test import SimpleTestCase
|
|
|
|
from apps.charts.calc import calculate_aspects
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Synthetic planet data — degrees chosen for predictable aspects
|
|
# Matches FAKE_PLANETS in test_populate_ephemeris.py
|
|
# ---------------------------------------------------------------------------
|
|
|
|
FAKE_PLANETS = {
|
|
'Sun': {'degree': 10.0}, # Aries
|
|
'Moon': {'degree': 130.0}, # Leo — 120° from Sun → Trine
|
|
'Mercury': {'degree': 250.0}, # Sagittarius — 120° from Sun → Trine
|
|
'Venus': {'degree': 40.0}, # Taurus — 90° from Moon → Square
|
|
'Mars': {'degree': 160.0}, # Virgo — 60° from Neptune → Sextile
|
|
'Jupiter': {'degree': 280.0}, # Capricorn — 120° from Mars → Trine
|
|
'Saturn': {'degree': 70.0}, # Gemini — 120° from Uranus → Trine
|
|
'Uranus': {'degree': 310.0}, # Aquarius — 60° from Sun (wrap) → Sextile
|
|
'Neptune': {'degree': 100.0}, # Cancer
|
|
'Pluto': {'degree': 340.0}, # Pisces
|
|
}
|
|
|
|
|
|
def _aspect_pairs(aspects):
|
|
"""Return a set of (planet1, planet2, type) tuples for easy assertion."""
|
|
return {(a['planet1'], a['planet2'], a['type']) for a in aspects}
|
|
|
|
|
|
class CalculateAspectsTest(SimpleTestCase):
|
|
|
|
def setUp(self):
|
|
self.aspects = calculate_aspects(FAKE_PLANETS)
|
|
|
|
# ── return shape ──────────────────────────────────────────────────────
|
|
|
|
def test_returns_a_list(self):
|
|
self.assertIsInstance(self.aspects, list)
|
|
|
|
def test_each_aspect_has_required_keys(self):
|
|
for aspect in self.aspects:
|
|
with self.subTest(aspect=aspect):
|
|
self.assertIn('planet1', aspect)
|
|
self.assertIn('planet2', aspect)
|
|
self.assertIn('type', aspect)
|
|
self.assertIn('angle', aspect)
|
|
self.assertIn('orb', aspect)
|
|
|
|
def test_each_aspect_type_is_a_known_name(self):
|
|
known = {'Conjunction', 'Sextile', 'Square', 'Trine', 'Opposition'}
|
|
for aspect in self.aspects:
|
|
with self.subTest(aspect=aspect):
|
|
self.assertIn(aspect['type'], known)
|
|
|
|
def test_angle_and_orb_are_floats(self):
|
|
for aspect in self.aspects:
|
|
with self.subTest(aspect=aspect):
|
|
self.assertIsInstance(aspect['angle'], float)
|
|
self.assertIsInstance(aspect['orb'], float)
|
|
|
|
def test_no_self_aspects(self):
|
|
for aspect in self.aspects:
|
|
self.assertNotEqual(aspect['planet1'], aspect['planet2'])
|
|
|
|
def test_no_duplicate_pairs(self):
|
|
pairs = [(a['planet1'], a['planet2']) for a in self.aspects]
|
|
self.assertEqual(len(pairs), len(set(pairs)))
|
|
|
|
# ── known aspects in FAKE_PLANETS ────────────────────────────────────
|
|
|
|
def test_sun_moon_trine(self):
|
|
"""Moon at 130° is exactly 120° from Sun at 10°."""
|
|
pairs = _aspect_pairs(self.aspects)
|
|
self.assertIn(('Sun', 'Moon', 'Trine'), pairs)
|
|
|
|
def test_sun_mercury_trine(self):
|
|
"""Mercury at 250° wraps to 120° from Sun at 10° (360-250+10=120)."""
|
|
pairs = _aspect_pairs(self.aspects)
|
|
self.assertIn(('Sun', 'Mercury', 'Trine'), pairs)
|
|
|
|
def test_moon_mercury_trine(self):
|
|
"""Moon 130° → Mercury 250° = 120°."""
|
|
pairs = _aspect_pairs(self.aspects)
|
|
self.assertIn(('Moon', 'Mercury', 'Trine'), pairs)
|
|
|
|
def test_moon_venus_square(self):
|
|
"""Moon 130° → Venus 40° = 90°."""
|
|
pairs = _aspect_pairs(self.aspects)
|
|
self.assertIn(('Moon', 'Venus', 'Square'), pairs)
|
|
|
|
def test_venus_neptune_sextile(self):
|
|
"""Venus 40° → Neptune 100° = 60°."""
|
|
pairs = _aspect_pairs(self.aspects)
|
|
self.assertIn(('Venus', 'Neptune', 'Sextile'), pairs)
|
|
|
|
def test_mars_neptune_sextile(self):
|
|
"""Mars 160° → Neptune 100° = 60°."""
|
|
pairs = _aspect_pairs(self.aspects)
|
|
self.assertIn(('Mars', 'Neptune', 'Sextile'), pairs)
|
|
|
|
def test_sun_uranus_sextile(self):
|
|
"""Sun 10° → Uranus 310° — angle = |10-310| = 300° → 360-300 = 60°."""
|
|
pairs = _aspect_pairs(self.aspects)
|
|
self.assertIn(('Sun', 'Uranus', 'Sextile'), pairs)
|
|
|
|
def test_mars_jupiter_trine(self):
|
|
"""Mars 160° → Jupiter 280° = 120°."""
|
|
pairs = _aspect_pairs(self.aspects)
|
|
self.assertIn(('Mars', 'Jupiter', 'Trine'), pairs)
|
|
|
|
def test_saturn_uranus_trine(self):
|
|
"""Saturn 70° → Uranus 310° = |70-310| = 240° → 360-240 = 120°."""
|
|
pairs = _aspect_pairs(self.aspects)
|
|
self.assertIn(('Saturn', 'Uranus', 'Trine'), pairs)
|
|
|
|
# ── orb bounds ────────────────────────────────────────────────────────
|
|
|
|
def test_orb_is_within_allowed_maximum(self):
|
|
max_orbs = {
|
|
'Conjunction': 8.0,
|
|
'Sextile': 6.0,
|
|
'Square': 8.0,
|
|
'Trine': 8.0,
|
|
'Opposition': 10.0,
|
|
}
|
|
for aspect in self.aspects:
|
|
with self.subTest(aspect=aspect):
|
|
self.assertLessEqual(
|
|
aspect['orb'], max_orbs[aspect['type']],
|
|
msg=f"{aspect['planet1']}-{aspect['planet2']} orb exceeds maximum",
|
|
)
|
|
|
|
def test_exact_trine_has_zero_orb(self):
|
|
"""Sun-Moon at exactly 120° should report orb of 0.0."""
|
|
sun_moon = next(
|
|
a for a in self.aspects
|
|
if a['planet1'] == 'Sun' and a['planet2'] == 'Moon'
|
|
)
|
|
self.assertAlmostEqual(sun_moon['orb'], 0.0, places=5)
|