adce568729
Ostatni etap rysowania kosmogramu: wizualizacje pochodne z aspektów pozazodiakalnych (LOG-07). Do tej pory paralele i antyscja były tylko w tabelach — teraz widać je na rzut oka. WYKRES DEKLINACJI (`render_declination`): - Pionowa skala deklinacji z równikiem (0°) i zwrotnikami (±ε, kreskowane) — ε bierzemy z `obliquity` w wyniku, więc granica jest dokładna dla daty. - Obiekty na osi X ułożone wg POSORTOWANEJ deklinacji, więc paralele (ta sama wysokość) lądują obok siebie. Łączniki: paralela zielona (jak koniunkcja), kontrparalela czerwona (jak opozycja) — te same barwy co linie na kole, grubość wg orbu. Dymek z nazwą zjawiska i orbem. - Strefa poza zwrotnikami cieniowana; obiekt OOB (out-of-bounds) w kolorze wyróżnienia + „OOB" w dymku. Od razu widać ciała o skrajnej deklinacji. OŚ ANTYSCJI (`render_antiscia`): - Ekliptyka rozwinięta w poziomą oś ze znakami; pionowo zaznaczona oś przesileń (0° Raka/Koziorożca) — lustro antyscji — i oś równonocy (0° Barana/Wagi) dla kontrantyscji. Pary połączone łukiem (zielony antyscja / czerwony kontrantyscja), z dymkiem. Bez par oś i obiekty i tak coś mówią. Oba w obu motywach (screen + print), więc gotowe też do PDF-a. Pokazują się na /chart pod odpowiednimi tabelami LOG-07. Testy: +13 (etap 6). Prezentacja: 170. Domyka etapy kosmogramu (PRE-12): 1 szkielet, 2 obiekty, 3 aspekty, 4 dopracowanie, 5 aspektarian, 6 deklinacja/antyscja. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
546 lines
22 KiB
Python
546 lines
22 KiB
Python
"""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ś AC–DC 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ś Asc–Dsc 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 # Sun–Mars trine 0.2° i Mercury–Venus 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 # Sun–Mars trine
|
||
assert "kwadratura" in svg # Moon–Mars 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
|
||
|
||
|
||
# ─────────────────────────── etap 5: aspektarian (PRE-18) ─────────────────
|
||
|
||
def test_aspectarian_is_well_formed_xml():
|
||
"""Aspektarian też wstawiamy surowo do strony — musi być poprawnym XML."""
|
||
svg = chartwheel.render_aspectarian(_chart_with_aspects())
|
||
svg = (svg.replace("var(--line)", "#000").replace("var(--accent)", "#000")
|
||
.replace("var(--muted)", "#000").replace("var(--ink)", "#000"))
|
||
doc = minidom.parseString(svg)
|
||
assert doc.documentElement.tagName == "svg"
|
||
|
||
|
||
def test_aspectarian_triangular_grid_has_right_cell_count():
|
||
"""N obiektów → przekątna (N komórek) + dolny-lewy trójkąt (N·(N−1)/2).
|
||
Fixture ma 5 obiektów: 5 + 10 = 15 komórek."""
|
||
svg = chartwheel.render_aspectarian(_chart_with_aspects())
|
||
assert svg.count("<rect") == 15
|
||
|
||
|
||
def test_aspectarian_puts_object_glyphs_on_diagonal():
|
||
"""Każdy obiekt raz — na przekątnej."""
|
||
svg = chartwheel.render_aspectarian(_chart_with_aspects())
|
||
for glyph in ("☉", "☽", "♀", "☿", "♂"):
|
||
assert glyph in svg
|
||
|
||
|
||
def test_aspectarian_shows_aspect_glyphs():
|
||
"""Aspekty z fixture: trygon (△), kwadratura (□), koniunkcja (☌)."""
|
||
svg = chartwheel.render_aspectarian(_chart_with_aspects())
|
||
assert "△" in svg and "□" in svg and "☌" in svg
|
||
|
||
|
||
def test_aspectarian_colors_match_the_wheel():
|
||
"""Te same barwy co linie na kole: niebieski/czerwony/zielony."""
|
||
svg = chartwheel.render_aspectarian(_chart_with_aspects())
|
||
assert "#6aa6c9" in svg # trine — harmonijny
|
||
assert "#cf6a6a" in svg # square — napięty
|
||
assert "#8fae7a" in svg # conjunction — zielony
|
||
|
||
|
||
def test_aspectarian_tight_aspect_is_bold():
|
||
"""Orb < 1° → glif pogrubiony (jak pogrubiona linia na kole). Dwa ciasne
|
||
aspekty w fixture (Sun–Mars 0.2°, Mercury–Venus 0.4°) → dwa pogrubienia."""
|
||
svg = chartwheel.render_aspectarian(_chart_with_aspects())
|
||
assert svg.count('font-weight="bold"') == 2
|
||
|
||
|
||
def test_aspectarian_cells_have_tooltips():
|
||
svg = chartwheel.render_aspectarian(_chart_with_aspects())
|
||
assert "<title>" in svg
|
||
assert "trygon" in svg and "orb" in svg
|
||
|
||
|
||
def test_aspectarian_tooltip_shows_applying_separating():
|
||
"""Gdy aspekt niesie kierunek (A/S), dymek to mówi po polsku."""
|
||
chart = _chart_with_aspects()
|
||
chart["aspects"][0]["as"] = "A" # Sun–Mars aplikacyjny
|
||
svg = chartwheel.render_aspectarian(chart)
|
||
assert "aplikacyjny" in svg
|
||
|
||
|
||
def test_aspectarian_empty_for_fewer_than_two_objects():
|
||
chart = _chart_with_objects()
|
||
chart["positions"] = chart["positions"][:1]
|
||
assert chartwheel.render_aspectarian(chart) == ""
|
||
|
||
|
||
def test_aspectarian_renders_grid_even_without_aspects():
|
||
"""Pusta siatka też niesie informację (które pary NIE mają aspektu) —
|
||
rysujemy przekątną z obiektami, bez glifów aspektów."""
|
||
chart = _chart_with_objects()
|
||
chart["aspects"] = []
|
||
svg = chartwheel.render_aspectarian(chart)
|
||
assert svg and "☉" in svg
|
||
for asp_glyph in ("△", "□", "☌", "⚹", "☍"):
|
||
assert asp_glyph not in svg
|
||
|
||
|
||
def test_aspectarian_object_without_glyph_is_safe():
|
||
"""Niekompletny obiekt (bez glifu) nie wywala siatki."""
|
||
chart = _chart_with_aspects()
|
||
chart["positions"].append({"name": "Chiron", "decimal": 100.0}) # brak glyph
|
||
svg = chartwheel.render_aspectarian(chart)
|
||
assert svg and "☉" in svg
|
||
|
||
|
||
def test_aspectarian_print_theme_has_no_css_vars():
|
||
"""Motyw druku (PDF) — konkretne kolory, żaden `var(--…)`."""
|
||
svg = chartwheel.render_aspectarian(_chart_with_aspects(), theme="print")
|
||
assert "var(--" not in svg
|
||
assert "#a83232" in svg # square napięty w wariancie druku
|
||
|
||
|
||
# ─────────────── etap 6: deklinacja i antyscja (LOG-07) ───────────────────
|
||
|
||
def _chart_with_declination():
|
||
chart = _chart_with_objects() # Sun/Moon/Venus/Mercury
|
||
chart["obliquity"] = 23.44
|
||
decs = {"Sun": 15.0, "Moon": 15.2, "Venus": -15.0, "Mercury": 25.5}
|
||
for p in chart["positions"]:
|
||
p["declination"] = decs[p["name"]]
|
||
if p["name"] == "Mercury":
|
||
p["out_of_bounds"] = True # 25.5° > ε=23.44 → OOB
|
||
chart["parallels"] = [
|
||
{"obj1": "Sun", "obj2": "Moon", "type": "parallel",
|
||
"orb": 0.2, "allowed": 1.0, "dec1": 15.0, "dec2": 15.2},
|
||
{"obj1": "Sun", "obj2": "Venus", "type": "contraparallel",
|
||
"orb": 0.0, "allowed": 1.0, "dec1": 15.0, "dec2": -15.0},
|
||
]
|
||
return chart
|
||
|
||
|
||
def test_declination_is_well_formed_xml():
|
||
svg = chartwheel.render_declination(_chart_with_declination())
|
||
svg = (svg.replace("var(--line)", "#000").replace("var(--accent)", "#000")
|
||
.replace("var(--muted)", "#000").replace("var(--ink)", "#000"))
|
||
assert minidom.parseString(svg).documentElement.tagName == "svg"
|
||
|
||
|
||
def test_declination_empty_for_fewer_than_two():
|
||
chart = _chart_with_declination()
|
||
chart["positions"] = chart["positions"][:1]
|
||
assert chartwheel.render_declination(chart) == ""
|
||
|
||
|
||
def test_declination_draws_equator_and_tropics():
|
||
"""Równik (0°) i dwie linie zwrotników (kreskowane) muszą być."""
|
||
svg = chartwheel.render_declination(_chart_with_declination())
|
||
assert ">0°<" in svg
|
||
assert svg.count("stroke-dasharray") >= 2 # ±ε kreskowane
|
||
|
||
|
||
def test_declination_object_glyphs_present():
|
||
svg = chartwheel.render_declination(_chart_with_declination())
|
||
for g in ("☉", "☽", "♀", "☿"):
|
||
assert g in svg
|
||
|
||
|
||
def test_declination_oob_object_is_highlighted():
|
||
"""Merkury poza zwrotnikami (OOB) w kolorze wyróżnienia + z „OOB" w dymku."""
|
||
svg = chartwheel.render_declination(_chart_with_declination())
|
||
assert "#ff9b6a" in svg # kolor retro/OOB (motyw ekranowy)
|
||
assert "OOB" in svg
|
||
|
||
|
||
def test_declination_parallel_and_contraparallel_colors():
|
||
"""Paralela jak koniunkcja (zielony), kontrparalela jak opozycja (czerwony)."""
|
||
svg = chartwheel.render_declination(_chart_with_declination())
|
||
assert "#8fae7a" in svg # parallel → zielony
|
||
assert "#cf6a6a" in svg # contraparallel → czerwony
|
||
assert "paralela" in svg # dymek
|
||
|
||
|
||
def test_declination_print_theme_has_no_css_vars():
|
||
svg = chartwheel.render_declination(_chart_with_declination(), theme="print")
|
||
assert "var(--" not in svg
|
||
|
||
|
||
def _chart_with_antiscia():
|
||
chart = _chart_with_objects()
|
||
chart["antiscia"] = [
|
||
{"obj1": "Sun", "obj2": "Moon", "type": "antiscion", "orb": 0.3, "allowed": 1.0},
|
||
{"obj1": "Venus", "obj2": "Mercury", "type": "contra_antiscion", "orb": 0.5, "allowed": 1.0},
|
||
]
|
||
return chart
|
||
|
||
|
||
def test_antiscia_is_well_formed_xml():
|
||
svg = chartwheel.render_antiscia(_chart_with_antiscia())
|
||
svg = (svg.replace("var(--line)", "#000").replace("var(--accent)", "#000")
|
||
.replace("var(--muted)", "#000").replace("var(--ink)", "#000"))
|
||
assert minidom.parseString(svg).documentElement.tagName == "svg"
|
||
|
||
|
||
def test_antiscia_empty_for_fewer_than_two():
|
||
chart = _chart_with_antiscia()
|
||
chart["positions"] = chart["positions"][:1]
|
||
assert chartwheel.render_antiscia(chart) == ""
|
||
|
||
|
||
def test_antiscia_draws_reflection_axes():
|
||
"""Oś przesileń (i równonocy) — linie kreskowane."""
|
||
svg = chartwheel.render_antiscia(_chart_with_antiscia())
|
||
assert svg.count("stroke-dasharray") >= 2
|
||
|
||
|
||
def test_antiscia_pair_colors_and_tooltip():
|
||
"""Antyscja jak koniunkcja (zielony łuk), kontrantyscja jak opozycja (czerwony)."""
|
||
svg = chartwheel.render_antiscia(_chart_with_antiscia())
|
||
assert "#8fae7a" in svg # antiscion → zielony
|
||
assert "#cf6a6a" in svg # contra_antiscion → czerwony
|
||
assert "antyscja" in svg and "<path" in svg
|
||
|
||
|
||
def test_antiscia_renders_axis_even_without_pairs():
|
||
"""Bez par (ciasny orb) oś i obiekty i tak coś mówią."""
|
||
chart = _chart_with_objects()
|
||
chart["antiscia"] = []
|
||
svg = chartwheel.render_antiscia(chart)
|
||
assert svg and "stroke-dasharray" in svg and "☉" in svg
|
||
|
||
|
||
def test_antiscia_print_theme_has_no_css_vars():
|
||
svg = chartwheel.render_antiscia(_chart_with_antiscia(), theme="print")
|
||
assert "var(--" not in svg
|