"""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(" 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 "" 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