Files
astrololo/services/presentation/tests/test_chartwheel.py
T
gitea 171deff2d1
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m36s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m35s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 28s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 21s
build / build (push) Successful in 48s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m34s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m36s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 31s
Testy / Kontrola składni wszystkich warstw (push) Successful in 17s
feat(prezentacja): linie aspektów na kosmogramie (PRE-12, etap 3)
Trzeci etap rysowania koła: linie aspektów w środku. Domyka rdzeń PRE-12 —
kosmogram ma teraz znaki, domy, osie, obiekty i aspekty.

Linie łączą PRAWDZIWE pozycje obiektów (nie rozsunięte glify — aspekt dotyczy
tego, gdzie planeta faktycznie stoi) na okręgu piasty, w środku koła.

Kodowanie jak w klasycznych programach:
- KOLOR = charakter aspektu: niebieski harmonijny (trygon, sekstyl), czerwony
  napięty (kwadratura, opozycja), zielony koniunkcja. Dwa kolory na środku od
  razu mówią „gdzie łatwo, gdzie tarcie".
- GRUBOŚĆ i JASNOŚĆ = orb: im ciaśniej, tym mocniej. Poniżej 1° wyraźne
  pogrubienie (wprost z wymagania) — najściślejsze aspekty rzucają się w oczy,
  a szerokie ledwo majaczą, żeby nie robić ze środka plątaniny.

Rysowane jako pierwsze, pod resztą: obiekty siedzą na R_PLANET=150, daleko od
piasty (R_HUB=48), więc glify i linie się nie stykają, a osie i szprychy lądują
na wierzchu.

Motyw druku (PDF, PRE-24) dostaje własne, ciemniejsze kolory aspektów bez
zmiennych CSS — samodzielny konwerter SVG arkusza nie widzi.

Weryfikacja na horoskopie referencyjnym: 24 aspekty, 5 ciasnych (<1°) faktycznie
pogrubionych; kolory zgodne z typem (9 czerwonych napiętych, 10 niebieskich
harmonijnych, 5 zielonych koniunkcji); print bez zmiennych CSS. Sprawdzone
wizualnie — pełny aspektarian, czytelny.

Testy: 8 nowych (linie obecne, kolor wg typu, pogrubienie <1°, prawdziwa pozycja
nie rozsunięta, pominięcie aspektu do obiektu bez pozycji, brak aspektów, motyw
druku). Całość: prezentacja 138 passed. PRE-12 -> Zrobione.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 17:44:25 +02:00

272 lines
11 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) == ""
# ───────────────────────────── linie aspektów ────────────────────────────
def _chart_with_aspects():
chart = _chart_with_objects()
chart["positions"].append({"name": "Mars", "decimal": 220.0, "glyph": "♂", "direction": "D"})
chart["aspects"] = [
{"obj1": "Sun", "obj2": "Mars", "aspect": "trine", "orb": 0.2, "allowed": 8.0},
{"obj1": "Moon", "obj2": "Mars", "aspect": "square", "orb": 5.0, "allowed": 8.0},
{"obj1": "Mercury", "obj2": "Venus", "aspect": "conjunction", "orb": 0.4, "allowed": 10.0},
]
return chart
def test_aspect_lines_are_drawn():
"""Każdy aspekt, którego oba obiekty mają pozycję, dostaje linię — porównujemy
TEN SAM wykres z aspektami i bez, żeby różnica była wyłącznie aspektami."""
chart = _chart_with_aspects()
with_asp = chartwheel.render(chart).count("<line")
chart_no = _chart_with_aspects()
chart_no["aspects"] = []
without = chartwheel.render(chart_no).count("<line")
assert with_asp - without == 3
def test_aspect_color_encodes_type():
"""Niebieski = harmonijny, czerwony = napięty, zielony = koniunkcja."""
svg = chartwheel.render(_chart_with_aspects())
assert "#6aa6c9" in svg # trine — harmonijny (niebieski)
assert "#cf6a6a" in svg # square — napięty (czerwony)
assert "#8fae7a" in svg # conjunction — zielony
def test_tight_aspect_is_bold():
"""Poniżej 1° linia pogrubiona (2.0) — wprost z wymagania. Szeroki aspekt
(square, orb 5°) NIE jest pogrubiony."""
svg = chartwheel.render(_chart_with_aspects())
assert 'stroke-width="2.0"' in svg # SunMars trine 0.2° i MercuryVenus 0.4°
# dwa ciasne aspekty → dwie pogrubione linie
assert svg.count('stroke-width="2.0"') == 2
def test_aspect_uses_true_position_not_spread():
"""Linia aspektu łączy PRAWDZIWE pozycje. Mercury(27.36) i Venus(0.41° od
siebie) są rozsuwane jako glify, ale linia koniunkcji ma iść między ich
faktycznymi punktami na piaście."""
chart = _chart_with_aspects()
asc = chart["angles"]["Asc"]["decimal"]
# punkt Wenus na piaście z prawdziwej długości
vx, vy = chartwheel._pt(27.77, asc, chartwheel.R_HUB)
svg = chartwheel.render(chart)
assert f'x1="{vx:.2f}"' in svg or f'x2="{vx:.2f}"' in svg
def test_aspect_to_missing_object_is_skipped():
"""Aspekt do obiektu bez pozycji nie może wywalić rysunku."""
chart = _chart_with_aspects()
chart["aspects"].append(
{"obj1": "Sun", "obj2": "Chiron", "aspect": "trine", "orb": 1.0, "allowed": 8.0})
svg = chartwheel.render(chart)
assert svg and "☉" in svg
def test_no_aspect_lines_without_aspects():
chart = _chart_with_objects() # bez klucza 'aspects'
a = chartwheel.render(chart).count("<line")
chart["aspects"] = []
assert chartwheel.render(chart).count("<line") == a
def test_print_theme_aspect_colors_have_no_css_vars():
"""Motyw druku (PDF) — konkretne kolory aspektów, bez zmiennych CSS."""
svg = chartwheel.render(_chart_with_aspects(), theme="print")
assert "var(--" not in svg
assert "#a83232" in svg # square napięty w wariancie druku