Files
astrololo/services/presentation/tests/test_chartwheel.py
T
gitea 623603b157
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m34s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m35s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 35s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 19s
build-render / build (push) Successful in 45s
build / build (push) Successful in 50s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m46s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m33s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 28s
Testy / Kontrola składni wszystkich warstw (push) Successful in 21s
feat: Markdown→LaTeX w PDF + dopracowanie kosmogramu (PRE-24, PRE-12 etap 4)
Dwie rzeczy w jednym PR (na prośbę).

1) MARKDOWN → LATEX w raporcie PDF (usługa render). Tekst od AI to markdown —
świetny w przeglądarce, ale w PDF `**gwiazdki**` i `## kratki` to dosłowny szum.
Nowy markdown_to_latex zamienia je na PRAWDZIWE formatowanie: nagłówki (degradowane
o poziom, żeby wpadły pod nasz \section „Interpretacja natalna", nie obok),
pogrubienia, kursywy, kod, listy punktowe i numerowane, linki (zostaje sam tekst,
URL bezużyteczny w druku).

Skaner liniowy, nie regex na całości — listy i akapity są wielolinijkowe.
Ucieczka znaków LaTeXa działa TAKŻE wewnątrz formatowania: `**wzrost 50%**` daje
`\textbf{wzrost 50\%}`, a nie zakomentowaną resztę linii. Sprawdzone na wrogim
tekście — `\end{document}` i `\input{}` w treści nie wyrywają się z dokumentu
nawet zawinięte w pogrubienie.

2) KOSMOGRAM — etap 4 (dopracowanie):
- Lots na kole: Fortuna ⊗ (jedyny Lot ze standardowym glifem) na promieniu POD
  pierścieniem planet i w akcencie — od razu widać, że to punkt wyliczony, nie
  ciało. Loty bez glifu (Spirit, Eros…) pomijamy, żeby nie zaśmiecać.
- Tooltipy: natywny <title> w SVG (bez JS-a). Obiekt → nazwa, pozycja, dom,
  retrogradacja. Linia aspektu → „Słońce trygon Mars · orb 0.20°" (polskie nazwy
  tylko do dymka; obliczenia trzymają angielskie). Fortuna → nazwa i pozycja.
- Stopnie cuspów przy szprychach. W whole sign cuspy są na 0° znaku — wtedy
  pomijamy dwanaście zbędnych zer; w systemach kwadratowych (porphyry) stopień
  realnie coś mówi, więc go pokazujemy.

Wszystko działa też w motywie druku (PDF): sprawdzone, że mimo Lotów, dymków
i cuspów SVG nadal nie ma zmiennych CSS.

Weryfikacja wizualna (porphyry): Fortuna przy AC, 12 stopni cuspów, 38 dymków
(24 aspekty + 13 obiektów + Fortuna). Testy: +12 markdown (render), +7 etap 4
(prezentacja). Całość: prezentacja 145, render 27.

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

340 lines
14 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
# ─────────────────────────── etap 4: dopracowanie ────────────────────────
def test_objects_have_hover_tooltips():
"""Najechanie na glif pokazuje nazwę, pozycję, dom i retrogradację (natywny
<title> w SVG — bez JS-a)."""
chart = _chart_with_objects()
for p in chart["positions"]: # fixture jest minimalny; dodaj etykiety
p["in_sign"] = "Tau 10°"
p["house"] = 9
svg = chartwheel.render(chart)
assert "<title>" in svg
assert "Sun · Tau 10° · dom 9" in svg # nazwa + pozycja + dom w dymku
def test_retrograde_object_says_so_in_tooltip():
svg = chartwheel.render(_chart_with_objects()) # Mercury jest Rx
assert "retrogradacja" in svg
def test_aspect_lines_have_polish_tooltips():
svg = chartwheel.render(_chart_with_aspects())
assert "trygon" in svg # SunMars trine
assert "kwadratura" in svg # MoonMars square
assert "orb" in svg
def test_part_of_fortune_is_drawn():
"""Fortuna (⊗) na kole — jedyny Lot ze standardowym glifem."""
chart = _chart_with_objects()
chart["lots"] = [
{"name": "Fortune", "longitude": 120.14, "glyph": "⊗", "in_sign": "Leo 0°", "house": 1},
{"name": "Spirit", "longitude": 137.66, "glyph": None, "in_sign": "Leo 17°"},
]
svg = chartwheel.render(chart)
assert "⊗" in svg
assert "Fortune · " in svg # dymek Lota
def test_lots_without_glyph_are_skipped():
"""Loty bez standardowego symbolu (Spirit, Eros…) nie zaśmiecają koła."""
chart = _chart_with_objects()
chart["lots"] = [{"name": "Spirit", "longitude": 137.66, "glyph": None}]
before = chartwheel.render(chart).count("<text")
chart["lots"] = []
after = chartwheel.render(chart).count("<text")
assert before == after # Spirit nic nie dorysował
def test_cusp_degrees_shown_for_quadrant_hidden_for_whole_sign():
"""Whole sign: cuspy na 0° znaku → pomijamy zbędne zera. System kwadratowy:
stopnie realnie coś mówią, więc je pokazujemy."""
whole = _sample_chart() # cuspy na 0/30/60…
assert chartwheel.render(whole).count('font-size="6.5"') == 0
quad = _sample_chart()
for i, c in enumerate(quad["cusps"]): # przesuń cuspy poza granice znaków
c["decimal"] = (c["decimal"] + 12.5) % 360.0
assert chartwheel.render(quad).count('font-size="6.5"') == 12
def test_print_theme_survives_stage4_features():
"""Motyw druku (PDF) nadal bez zmiennych CSS mimo Lotów, dymków i cuspów."""
chart = _chart_with_aspects()
chart["lots"] = [{"name": "Fortune", "longitude": 120.0, "glyph": "⊗", "in_sign": "Leo 0°"}]
svg = chartwheel.render(chart, theme="print")
assert "var(--" not in svg
assert "⊗" in svg and "<title>" in svg