"""Porównanie systemów domów naraz (PRE-05). JS-a tu nie ma — sprawdzamy strukturalnie: formularz pozwala zaznaczyć kilka systemów, klient przekazuje je do logiki, a szablon pokazuje kuspy obok siebie. """ import pathlib import pytest APP = pathlib.Path(__file__).resolve().parents[1] / "app" CHART = (APP / "templates" / "chart.html").read_text(encoding="utf-8") COMPILE = (APP / "templates" / "compile.html").read_text(encoding="utf-8") OPTS = (APP / "templates" / "_form_options.html").read_text(encoding="utf-8") # wspólne opcje TABLES = (APP / "templates" / "_result_tables.html").read_text(encoding="utf-8") # wspólne tabele MAIN = (APP / "main.py").read_text(encoding="utf-8") from app.house_systems import HOUSE_SYSTEMS, LIMITED, label as house_label # noqa: E402 CLIENT = (APP / "clients" / "logic_client.py").read_text(encoding="utf-8") def test_both_forms_use_shared_option_partial(): """Opcje w jednym pliku — oba formularze go dołączają (koniec duplikacji).""" assert '{% include "_form_options.html" %}' in CHART assert '{% include "_form_options.html" %}' in COMPILE def _render(name: str, **ctx) -> str: """Renderuje szablon z tymi samymi globalsami, co produkcja. Szablony budują listę systemów PĘTLĄ po katalogu, więc sprawdzanie ich surowego tekstu nic już nie mówi — trzeba je wykonać. Przy okazji test łapie literówki w Jinja, których grep po źródle nigdy nie wyłapał.""" from jinja2 import Environment, FileSystemLoader env = Environment(loader=FileSystemLoader(str(APP / "templates")), autoescape=True) env.globals.update(HOUSE_SYSTEMS=HOUSE_SYSTEMS, HOUSE_LIMITED=LIMITED, house_label=house_label, static=lambda p: p, # Domyślnie renderujemy jak dla konta z PEŁNYMI uprawnieniami — # inaczej testy opcji sprawdzałyby pusty formularz. Ograniczenia # mają własne testy w test_kontrola_dostepu.py. can=lambda request, key: ctx.pop("_denied", None) != key, nav_screens=lambda request: []) return env.get_template(name).render(**ctx) def test_form_offers_every_system_the_engine_advertises(): """Każdy system z katalogu ma checkbox. Katalog jest jednym źródłem nazw, więc dopisanie systemu bez odświeżenia UI zapali się tutaj.""" html = _render("_form_options.html", form={}) for value, name in HOUSE_SYSTEMS: assert f'name="house_systems" value="{value}"' in html, f"brak checkboxa {value}" assert name in html, f"brak nazwy {name}" def test_form_marks_systems_with_a_domain_limit(): """Placidus i Koch nie istnieją za kołem podbiegunowym — UI ma uprzedzać ZANIM ktoś policzy horoskop dla Tromsø, nie dopiero w ostrzeżeniu wyniku.""" html = _render("_form_options.html", form={}) assert LIMITED, "katalog nie oznacza żadnego systemu jako ograniczonego" for value, name in HOUSE_SYSTEMS: starred = f"{name} *" in html assert starred == (value in LIMITED), f"{value}: gwiazdka={starred}" def test_handler_accepts_and_passes_house_systems(monkeypatch): """Zaznaczone systemy DOCHODZĄ do warstwy logicznej. Poprzednia wersja szukała napisu `house_systems=house_systems` w main.py i pękała, gdy argument zaczął przechodzić przez słownik opcji — mimo że zachowanie zostało poprawne. Sprawdzamy więc, co naprawdę wyszło na dół.""" seen = {} def spy(**kw): seen.update(kw) return _minimal_chart() c = _client_with_stub_logic(monkeypatch, _minimal_chart()) from app.main import logic monkeypatch.setattr(logic, "positions", spy) r = c.post("/", data={**FORM, "house_systems": ["koch", "campanus"]}) assert r.status_code == 200, r.text[:300] assert seen["house_systems"] == ["koch", "campanus"] assert seen["house_system"] == "placidus" def test_client_forwards_house_systems_to_logic(): assert '"house_systems": house_systems' in CLIENT def test_comparison_table_renders_only_for_multiple_systems(): """Bez dodatkowych systemów nie pokazujemy pustej tabeli porównania.""" assert "result.house_systems | length > 1" in TABLES assert "Porównanie systemów domów" in TABLES def test_comparison_table_shows_cusp_per_system(): """Tabela iteruje po systemach i po 12 domach — kusp w każdej komórce.""" assert "for hs in result.house_systems" in TABLES assert "hs.cusps[i].in_sign" in TABLES def test_checkbox_state_survives_submit(): """Zaznaczone systemy zostają zaznaczone po przeliczeniu.""" html = _render("_form_options.html", form={"house_systems": ["koch", "campanus"]}) for value, _ in HOUSE_SYSTEMS: marker = f'value="{value}" checked' assert (marker in html) == (value in ("koch", "campanus")), value def test_fallback_notice_is_shown_prominently(): """Po fallbacku kosmogram wygląda bezbłędnie — ostrzeżenie jest jedynym sygnałem, że podział domów jest z innego systemu.""" html = _render("_result_tables.html", result={"house_warnings": ["UWAGA: system domów nie ma definicji"]}) # Klasa nazywa się neutralnie: `house-warning` siedziała też na ekranie # logowania i na ekranie plików, czyli nazwa z astrologii lądowała tam, # gdzie astrologii ma nie być widać. assert "notice-warn" in html assert "UWAGA: system domów nie ma definicji" in html def test_no_notice_when_nothing_was_substituted(): html = _render("_result_tables.html", result={"house_warnings": []}) assert "house-warning" not in html # ── ścieżka PRZEZ handlery, nie obok nich ─────────────────────────────── # Dwa błędy przeszły przez komplet testów szablonowych, bo żaden nie wywołał # POST-a: odwołanie do nieistniejącej zmiennej w handlerze strony głównej oraz # użycie parametru formularza, którego w sygnaturze nie było. Oba dają 500 na # żywo i oba łapie dopiero prawdziwe żądanie. def _client_with_stub_logic(monkeypatch, chart): import os os.environ.pop("APP_PASSWORD", None) from starlette.testclient import TestClient from app.main import app, logic monkeypatch.setattr(logic, "positions", lambda **kw: chart) return TestClient(app) def _minimal_chart(): cusps = [{"house": i + 1, "sign": "Aries", "in_sign": "0°00'00''", "decimal": float(i * 30), "sign_glyph": "♈"} for i in range(12)] ang = {k: {"name": k, "sign": "Aries", "in_sign": "0°00'00''", "decimal": 0.0, "sign_glyph": "♈"} for k in ("Asc", "MC", "Dsc", "IC")} return {"engine": "test", "positions": [], "cusps": cusps, "angles": ang, "sign_glyphs": [{"sign": "Aries", "glyph": "♈"}], "house_system": "porphyry", "house_system_requested": "placidus", "house_warnings": ["UWAGA: system domów „placidus” nie ma definicji"]} FORM = {"date": "1984-04-30", "time": "11:20", "tz_offset": "2", "lat": "50.06", "lon": "19.94", "house_system": "placidus"} def test_chart_page_survives_a_real_post(monkeypatch): c = _client_with_stub_logic(monkeypatch, _minimal_chart()) r = c.post("/", data=FORM) assert r.status_code == 200, r.text[:400] assert "nie ma definicji" in r.text, "ostrzeżenie o fallbacku nie dotarło na stronę" def test_compile_page_survives_a_real_post(monkeypatch): c = _client_with_stub_logic(monkeypatch, _minimal_chart()) r = c.post("/compile", data=FORM) assert r.status_code == 200, r.text[:400] @pytest.mark.parametrize("orientation", ("asc", "aries")) def test_wheel_orientation_reaches_the_renderer(monkeypatch, orientation): """Wybór obrotu koła musi dojść z formularza do rysunku — i wrócić zaznaczony, żeby nie gubił się przy przeliczeniu.""" c = _client_with_stub_logic(monkeypatch, _minimal_chart()) r = c.post("/", data={**FORM, "wheel_orientation": orientation}) assert r.status_code == 200, r.text[:400] assert f'value="{orientation}" checked' in r.text # ── układ strony przy komplecie systemów ──────────────────────────────── # Regresja z pomiaru na żywej stronie: przy 13 zaznaczonych systemach tabela # porównania miała 14 kolumn i 1863 px szerokości, a że stała w rodzicu bez # overflow-x, ROZPYCHAŁA CAŁY DOKUMENT — 1713 px przy oknie 1280 px, czyli # poziomy pasek przewijania na body i rozjechany układ. # Arkusz jest teraz rozbity per uprawnienie (arkusz wspólny + arkusze ekranów), # bo jeden plik ze wszystkimi selektorami był spisem funkcji programu dla # każdego, kto go pobrał. Reguły szukamy w całości — który arkusz ją niesie, # pilnuje osobny test pokrycia. CSS = "\n".join(f.read_text(encoding="utf-8") for f in sorted([*(APP / "static").glob("*.css"), *(APP / "dodatki" / "static").glob("*.css")])) def test_comparison_table_scrolls_instead_of_widening_the_page(): """Tabela ma własny kontener przewijany — przewija się ona, nie strona.""" html = _render("_result_tables.html", result={ "house_systems": [{"system": v, "cusps": [ {"sign_glyph": "♈", "in_sign": "0°00'00''"} for _ in range(12)]} for v, _ in HOUSE_SYSTEMS], "house_warnings": []}) assert '