Files
astrololo/services/presentation/tests/test_chartwheel.py
T
gitea 40c9bf7988
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 11m57s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m55s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 38s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 27s
build / build (push) Successful in 1m4s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m46s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m54s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 33s
Testy / Kontrola składni wszystkich warstw (push) Successful in 25s
feat(prezentacja): obiekty na kosmogramie — glify, stopnie, retrogradacja (PRE-12)
Etap 2 rysowania koła (po szkielecie): obiekty na swoich pozycjach.

Każdy obiekt dostaje: kreske na wewnetrznej krawedzi pasa w PRAWDZIWEJ pozycji,
glif, stopien w znaku i znacznik retrogradacji ℞ (dodatkowo kolorem, zeby dalo
sie ja wylapac nie czytajac znak po znaku).

ROZSUWANIE CIASNYCH SKUPISK — sedno tego etapu. W horoskopie referencyjnym
Merkury i Wenus dzieli 0,41°, Wenus i Ksiezyc 3,68°: bez rozsuwania glify
rysuja sie jeden na drugim. Rozsuwamy tylko GLIFY; kreska zostaje w prawdziwej
pozycji, a gdy glif jest odsuniety, laczymy je cienka linia odniesienia — wykres
nie moze klamac o tym, gdzie planeta faktycznie stoi.

Bledy zlapane przy weryfikacji (oba wyszly z pomiarow, nie z „wyglada dobrze"):

1. PODPISY STOPNI zlewaly sie w skupiskach. O ciasnocie decyduje nie glif, tylko
   podpis — lezy blizej srodka (r=130), gdzie ten sam kat to mniej pikseli.
   Stad odstep 8° zamiast 7°, podpis bez „°" (jak w programach astrologicznych)
   i mniejszy font. Teraz: glify min 20,9 px, podpisy 18,1 px (prog 16).

2. ROZSUWANIE NIE DZIALALO na prawdziwych danych — Wenus ladowala DOKLADNIE na
   Ksiezycu (0,3 px). Przyczyna: odstep liczony modulo 360. Przesuniecie, ktore
   przerzucalo obiekt ZA sasiada, dawalo luke ~359,9° zamiast ujemnej, wiec
   algorytm uznawal, ze jest luzem, i konczyl. Poprawka: rozwijamy katy do osi
   MONOTONICZNEJ, gdzie ujemna luka zostaje ujemna i zawsze sie ja wylapie.
   Test regresyjny na dokladnie tych danych; sprawdzony sabotazem (po przywroceniu
   modulo czerwienieje).

Etykiety osi (AC/DC/MC/IC) przeniesione POZA kolo — w srodku wchodzily w pierscien
obiektow i zaslanialy glify (Ksiezyc znikal pod linia MC). ViewBox 440→470, kolo
bez zmian, margines mieści etykiety.

Testy: 11 nowych (regresja rozsuwania, zachowanie kolejnosci, obiekty bez kolizji
nieruszone, zawiniecie przez 0°, awaryjny rowny rozklad, stopien w znaku,
retrogradacja, niekompletny obiekt nie wywala rysunku). Calosc: prezentacja 44,
logika 265 / 1 skip. Potwierdzone wizualnie: cale skupisko Slonce/Ksiezyc/Wenus/
Merkury czytelne i rozdzielone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 15:49:51 +02:00

196 lines
7.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Kosmogram — koło horoskopowe jako SVG (PRE-12).
Sprawdzamy, że SVG jest POPRAWNY (parsuje się jako XML — inaczej wstawiony do
strony rozjedzie render) i że niesie to, co ma nieść: pierścień 12 znaków, cztery
osie, numery domów i obiekty. Geometria (Asc po lewej) oraz rozsuwanie ciasnych
skupisk sprawdzone liczbowo, nie na oko.
"""
import pytest
import xml.dom.minidom as minidom
from app import chartwheel
def _sample_chart(asc=128.9, mc=5.0):
"""Minimalny wynik z warstwy logiki: osie + cuspy (whole sign) + pierścień znaków."""
signs = ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"]
glyphs = ["♈", "♉", "♊", "♋", "♌", "♍", "♎", "♏", "♐", "♑", "♒", "♓"]
asc_sign = int(asc // 30)
cusps = [{"house": i + 1, "sign": signs[(asc_sign + i) % 12],
"decimal": (asc_sign * 30 + i * 30) % 360, "sign_glyph": glyphs[(asc_sign + i) % 12]}
for i in range(12)]
return {
"angles": {
"Asc": {"name": "Asc", "decimal": asc, "sign": signs[asc_sign]},
"MC": {"name": "MC", "decimal": mc},
"Dsc": {"name": "Dsc", "decimal": (asc + 180) % 360},
"IC": {"name": "IC", "decimal": (mc + 180) % 360},
},
"cusps": cusps,
"sign_glyphs": [{"sign": s, "glyph": g} for s, g in zip(signs, glyphs)],
}
def _rendered():
return chartwheel.render(_sample_chart())
def test_output_is_well_formed_xml():
"""Wstawiamy surowy SVG do strony — musi być poprawnym XML, bo inaczej
zepsuje cały dokument."""
svg = _rendered().replace("var(--line)", "#000").replace(
"var(--accent)", "#000").replace("var(--muted)", "#000")
doc = minidom.parseString(svg) # rzuci wyjątkiem, gdy błędny
assert doc.documentElement.tagName == "svg"
def test_has_twelve_sign_glyphs():
assert _rendered().count('class="glyph"') == 12
def test_has_all_four_axis_labels():
svg = _rendered()
for label in (">AC<", ">DC<", ">MC<", ">IC<"):
assert label in svg
def test_has_house_numbers_one_to_twelve():
svg = _rendered()
for h in range(1, 13):
assert f">{h}<" in svg
def test_ascendant_is_on_the_left():
"""Konwencja: Asc po lewej. Sprawdzamy oś ACDC liczbowo — punkt Asc musi mieć
x wyraźnie mniejszy od środka (220), a Dsc większy."""
chart = _sample_chart(asc=128.9)
asc = chart["angles"]["Asc"]["decimal"]
ax, ay = chartwheel._pt(asc, asc, chartwheel.R_ZOD)
dx, dy = chartwheel._pt(asc + 180, asc, chartwheel.R_ZOD)
assert ax < chartwheel._CX - 100, "Asc powinien być po lewej"
assert dx > chartwheel._CX + 100, "Dsc powinien być po prawej"
assert abs(ay - chartwheel._CY) < 1e-6, "oś AscDsc jest pozioma"
def test_longitude_increases_counterclockwise():
"""Asc+90° (II dom) ląduje na DOLE koła (y > środek, bo w SVG y rośnie w dół)."""
asc = 100.0
_, y = chartwheel._pt(asc + 90, asc, chartwheel.R_ZOD)
assert y > chartwheel._CY + 100
def test_empty_when_no_angles():
"""Silnik bez osi/domów (brak sidereal) → brak koła, nie wyjątek."""
assert chartwheel.render({"positions": []}) == ""
assert chartwheel.render({"angles": {}, "cusps": [], "sign_glyphs": []}) == ""
# ───────────────────────── rozsuwanie nakładających się obiektów ─────────
# Prawdziwe długości z horoskopu referencyjnego (1984-04-30, Kraków), w kolejności
# DEFAULT_OBJECTS. Merkury i Wenus dzieli 0,41°, Wenus i Księżyc 3,68° — bez
# rozsuwania glify rysują się jeden na drugim.
_REAL = [40.210, 31.449, 27.355, 27.767, 234.520, 282.962, 223.306,
252.811, 271.220, 210.477, 68.158, 248.158, 345.667]
def _min_gap(angles):
s = sorted(a % 360.0 for a in angles)
return min((s[(i + 1) % len(s)] - s[i]) % 360.0 for i in range(len(s)))
def test_spread_separates_real_chart_cluster():
"""REGRESJA: przy liczeniu odstępu modulo 360 przesunięcie, które przerzucało
obiekt ZA sąsiada, dawało lukę ~359,9° zamiast ujemnej — algorytm uznawał, że
jest luzem, i kończył z Wenus dokładnie na Księżycu (0,3 px od siebie)."""
out = chartwheel.spread(_REAL)
assert _min_gap(out) >= chartwheel.MIN_SEP - 1e-6
def test_spread_preserves_order():
out = chartwheel.spread(_REAL)
by_new = [i for i, _ in sorted(enumerate(out), key=lambda p: p[1])]
by_old = [i for i, _ in sorted(enumerate(_REAL), key=lambda p: p[1] % 360.0)]
assert by_new == by_old
def test_spread_leaves_roomy_objects_untouched():
"""Obiekty, które nikomu nie wchodzą w drogę, mają zostać na swoim miejscu —
wykres ma kłamać jak najmniej."""
out = chartwheel.spread(_REAL)
node = _REAL.index(68.158) # Węzeł Płn. — 28° od najbliższego
assert out[node] == pytest.approx(_REAL[node], abs=1e-6)
def test_spread_handles_wrap_around_zero():
"""Skupisko na styku 0/360° też musi się rozsunąć."""
out = chartwheel.spread([358.0, 359.0, 1.0, 2.0])
assert _min_gap(out) >= chartwheel.MIN_SEP - 1e-6
def test_spread_falls_back_to_even_layout_when_impossible():
"""Gdy obiektów jest tyle, że min. odstęp się nie zmieści — rozkładamy równo,
zamiast kręcić się w pętli."""
many = [10.0] * 60 # 60 × 8° = 480° > 360°
out = chartwheel.spread(many)
assert _min_gap(out) == pytest.approx(360.0 / 60, abs=1e-6)
def test_spread_single_object_is_identity():
assert chartwheel.spread([42.0]) == [42.0]
# ─────────────────────────────── obiekty na kole ─────────────────────────
def _chart_with_objects():
chart = _sample_chart()
chart["positions"] = [
{"name": "Sun", "decimal": 40.21, "glyph": "☉", "direction": "D"},
{"name": "Moon", "decimal": 31.45, "glyph": "☽", "direction": "D"},
{"name": "Venus", "decimal": 27.77, "glyph": "♀", "direction": "D"},
{"name": "Mercury", "decimal": 27.36, "glyph": "☿", "direction": "Rx"},
]
return chart
def test_all_objects_are_drawn():
svg = chartwheel.render(_chart_with_objects())
for glyph in ("☉", "☽", "♀", "☿"):
assert glyph in svg
def test_degree_in_sign_is_labelled():
"""Stopień W ZNAKU (nie długość absolutna): Słońce 40,21° → 10 Byka."""
svg = chartwheel.render(_chart_with_objects())
assert ">10<" in svg # Słońce
assert ">1<" in svg # Księżyc (31,45 → 1 Byka)
def test_retrograde_is_marked():
svg = chartwheel.render(_chart_with_objects())
assert "℞" in svg
def test_no_retrograde_marker_when_all_direct():
chart = _chart_with_objects()
for p in chart["positions"]:
p["direction"] = "D"
assert "℞" not in chartwheel.render(chart)
def test_objects_without_longitude_are_skipped():
"""Niekompletny obiekt nie może wywalić całego rysunku."""
chart = _chart_with_objects()
chart["positions"].append({"name": "Broken", "glyph": "?"})
svg = chartwheel.render(chart)
assert svg and "☉" in svg
def test_empty_when_cusps_lack_longitude():
"""Starsze wyniki bez `decimal` w cuspach — degradujemy do pustego, nie sypiemy."""
chart = _sample_chart()
for c in chart["cusps"]:
del c["decimal"]
assert chartwheel.render(chart) == ""