Files
astrololo/services/presentation/tests/test_compile.py
T
gitea c1f5bea9f7
Testy / Testy warstwy logicznej (silnik) (push) Failing after 4m46s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m32s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 7s
Testy / Kontrola składni wszystkich warstw (push) Successful in 5s
astrololo: ekrany i eksport jako moduły, katalog z rejestracji (3/5)
Trzeci z pięciu kroków. Zmieniłem jego zakres wobec planu i warto wiedzieć
dlaczego: pierwotnie miała to być deduplikacja pięciu kopii link_crypto.py, ale
po zrobieniu PR 2 widać, że astroklienta blokuje co innego — ekrany, których nie
ma mieć, siedzą wewnątrz jednego main.py. Deduplikacja kryptografii jest realnym
długiem, ale niczego nie blokuje.

PODZIAŁ. main.py (1023 linie) rozpadł się na podstawa.py (wspólne obiekty
i pomocnicy), jedenaście modułów w app/ekrany/ i main.py, który jest już samym
ZŁOŻENIEM: lista importów JEST definicją produktu. Podział zrobiony mechanicznie,
z osobnym sprawdzeniem, że żadna sekcja nie wołała pomocnika z innej (nie wołała).

KATALOG Z REJESTRACJI. Dotąd wszystkie funkcje były wypisane w features.py, więc
obraz produktu, który części z nich nie ma, i tak niósł ich nazwy — spis funkcji,
których nie ma jak włączyć. Teraz ekran zgłasza siebie, swoje trasy i swoje
zasoby przy imporcie własnego modułu, a features.py nie wymienia ani jednego
ekranu. Kolejność w nawigacji jest jawna (`kolejnosc`), żeby nie rządziła nią
kolejność importów.

To samo dotyczy nawigacji administratora: odsyłacz do ekranu kont był wpisany na
sztywno w base.html, więc w węższym produkcie zostawał martwy link i nazwa
ekranu, którego nie ma.

EKSPORT JAKO MODUŁ. Zgodnie z ustaleniem eksport jest funkcją administracyjną,
więc musi dać się usunąć. app/moduly/eksport/ zabiera arkusz, trasę PDF-a i akcję
formularza. „Można, ale nie temu kontu" i „nie ma takiej możliwości" to dwie
różne gwarancje, a eksport wynosi najwięcej treści baz naraz.

MOST ODKRYWA MODUŁY. Skoro modułów jest więcej niż jeden, most nie może ich znać
z nazwy — nazwa nieobecnego modułu jechałaby do obrazu, w którym go nie ma.
Przechodzi więc po podkatalogach app/moduly/ i pyta każdy, co wnosi. Katalog
generowania przeniesiony z app/dodatki na app/moduly/dodatki.

ZNALEZIONE PRZY OKAZJI. Po wydzieleniu eksportu okazało się, że jego ścieżki
SZCZĘŚLIWEJ nie sprawdzał żaden test — badano wyłącznie odmowę dla konta bez
uprawnienia. Moduł dostaje zależności z wywołania montującego, więc brak jednej
z nich wyszedłby dopiero przy pierwszym kliknięciu. Dopisany test funkcjonalny
(realny arkusz, sprawdzany aż do nagłówka ZIP-a) i brakujące zależności.

Cztery komentarze w plikach współdzielonych wymieniały zakładkę „Skompiluj",
w tym wheelzoom.js ze wzmianką o „przyszłej zakładce" — to samo zgłoszenie, które
audyt podnosił wcześniej.

TEST ZŁOŻENIA. Buduje węższy produkt NAPRAWDĘ: kopiuje drzewo, usuwa cztery
ekrany i oba moduły, uruchamia aplikację w OSOBNYM PROCESIE (importy są
zapamiętywane, więc sprawdzanie tego w procesie, który moduł już zaimportował,
dawałoby wynik fałszywie pozytywny) i sprawdza, że wstaje, że zachowane ekrany
oddają 200, że usunięte oddają 404 (nie 403 i nie 500), że katalog opisuje ten
obraz, i że w nawigacji nie ma martwych odsyłaczy.

Napisałem najpierw ostrzejszy test — „nazwa ekranu nie pada poza jego modułem" —
i go wyrzuciłem: zgłaszał wzmianki o Horoskopie w plikach współdzielonych, choć
astroklient Horoskop MA. Ślad ma znaczenie wyłącznie wobec konkretnego złożenia,
więc sprawdzenie należy do produktu, nie do mechanizmu.

Testy: presentation 368, logic 342, data 37, render 41.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 13:50:34 +02:00

256 lines
11 KiB
Python

"""Zakładka „Skompiluj" — zbiorczy raport (PRE-23).
Raport składa się z trzech kawałków liczonych w różnych miejscach: horoskopu
(serwer), interpretacji natalnej i predykcji okresowych (magazyn przeglądarki).
Pilnujemy, żeby zakładka umiała je zebrać, a przede wszystkim — żeby POWIEDZIAŁA,
czego brakuje. Niekompletny raport, o którym dowiadujesz się dopiero z PDF-a,
byłby najgorszym wynikiem.
"""
import pathlib
from starlette.testclient import TestClient
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
from tests.skladanie import zloz, zloz_kod
# Szablon z rozwiniętymi wstawkami modułów — patrz tests/skladanie.py.
TPL = zloz("compile.html")
BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8")
JS = (APP / "static" / "compile.js").read_text(encoding="utf-8")
# Skrypty generowania mieszkają w module `app/ai/` — katalogu, którego
# w obrazie bez tej funkcji po prostu nie ma.
NATAL_JS = (APP / "moduly" / "dodatki" / "static" / "natal.js").read_text(encoding="utf-8")
INTERPRET = zloz("interpret.html")
# Trasy mieszkają w app/ekrany/* — patrz tests/skladanie.py.
MAIN = zloz_kod()
def _client():
import os
os.environ.pop("APP_PASSWORD", None) # bez bramki logowania w teście
from app.main import app
return TestClient(app)
# ───────────────────────────────── nawigacja i trasa ─────────────────────
def test_tab_is_in_the_menu():
"""Zakładka jest w KATALOGU FUNKCJI — stamtąd buduje się nawigacja.
Menu nie jest już listą wpisaną w base.html: powstaje z uprawnień konta
(PRE-26), więc szukanie w szablonie sztywnego linku nic by nie mówiło.
Że nawigacja faktycznie renderuje katalog, sprawdza test_kontrola_dostepu."""
from app import features
tab = features.BY_KEY["compile"]
assert tab in features.SCREENS
assert tab.href and tab.label
def test_get_renders_empty_form():
r = _client().get("/compile")
assert r.status_code == 200
assert "Złóż podsumowanie" in r.text
def test_routes_exist():
assert '@app.get("/compile"' in MAIN and '@app.post("/compile"' in MAIN
# ─────────────────────────── kolejność w raporcie ────────────────────────
def test_report_order_matches_the_brief():
"""Prośba partnerów: najpierw imię i nazwisko, potem wprowadzone dane, potem
RYSUNEK kosmogramu, a dopiero po nim interpretacja natalna i predykcje."""
person = TPL.index('id="reportPerson"')
wheel = TPL.index("wheel_svg")
natal = TPL.index('id="reportNatal"')
preds = TPL.index('id="reportPredictions"')
assert person < wheel < natal < preds
def test_person_name_heads_the_report():
assert 'id="reportPerson"' in TPL
assert "form.person" in TPL
# ───── wszystkie rysunki w raporcie i w PDF (nie tylko koło) ─────
# Zasada: co pokazujemy na stronie, ma trafić do podsumowania i do PDF-a.
def test_report_shows_every_chart_figure():
"""Raport «Skompiluj» pokazuje wszystkie rysunki, nie tylko koło."""
for cls in ("wheel-fig", "aspectarian-fig", "declination-fig", "antiscia-fig"):
assert cls in TPL, f"brak {cls} w raporcie"
def test_compile_page_renders_all_figures():
"""Handler /compile liczy wszystkie rysunki do podglądu raportu."""
for var in ("aspectarian_svg", "declination_svg", "antiscia_svg"):
assert var in MAIN, f"/compile nie ustawia {var}"
def test_pdf_bundles_all_figures_in_print_theme(monkeypatch):
"""compile_pdf składa KOMPLET rysunków w motywie DRUKU.
Sprawdzamy przez WYWOŁANIE trasy, nie przez szukanie tekstu w main.py:
poprzednia wersja greppowała `render(chart, theme="print")` i pękała przy
każdym dopisaniu argumentu, mimo że zachowanie zostawało poprawne."""
seen: dict[str, str] = {}
from app import chartwheel
from app.clients.render_client import RenderClient
from app.main import logic
for name in ("render", "render_aspectarian", "render_declination", "render_antiscia"):
original = getattr(chartwheel, name)
def spy(chart, theme="screen", _n=name, _o=original, **kw):
seen[_n] = theme
return _o(chart, theme=theme, **kw)
monkeypatch.setattr(chartwheel, name, spy)
monkeypatch.setattr(logic, "positions", lambda **kw: _pdf_sample_chart())
monkeypatch.setattr(RenderClient, "pdf", lambda self, report: b"%PDF-1.4 stub")
r = _client().post("/compile/pdf", json={"person": "Jan", "data": {
"date": "1984-04-30", "time": "11:20", "tz_offset": 2, "lat": 50.06, "lon": 19.94}})
assert r.status_code == 200, r.text[:300]
assert set(seen) == {"render", "render_aspectarian",
"render_declination", "render_antiscia"}, seen
assert set(seen.values()) == {"print"}, seen
def _pdf_sample_chart() -> dict:
cusps = [{"house": i + 1, "sign": "Aries", "in_sign": "0", "decimal": float(i * 30),
"sign_glyph": "♈"} for i in range(12)]
ang = {k: {"name": k, "sign": "Aries", "in_sign": "0", "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": "equal"}
# ──────────────────────── zbieranie materiału z magazynów ────────────────
def test_reads_stores_through_their_api_not_raw_storage():
"""Format danych ma jednego właściciela — moduł, który je zapisuje.
Sięganie tu wprost do localStorage rozjechałoby się przy pierwszej zmianie."""
assert "window.astrololoNatal" in JS
assert "window.astrololoPredictions" in JS
# chodzi o brak BEZPOŚREDNIEGO dostępu, nie o samo słowo (pada w komentarzu)
assert "localStorage.getItem" not in JS
assert "localStorage.setItem" not in JS
def test_stores_are_loaded_on_the_tab():
for src in ("natal.js", "predictions.js", "compile.js"):
assert src in TPL, f"brak {src} na zakładce"
def test_stores_load_before_the_assembler():
"""Szukamy TAGÓW skryptów, nie samych nazw — te padają też w komentarzach.
Statyka jedzie przez helper cache-bustingu (PRE-26): src="{{ static('X') }}"."""
def tag(name):
return TPL.index("static('" + name + "')")
assert tag("natal.js") < tag("compile.js")
assert tag("predictions.js") < tag("compile.js")
# ─────────────────────────── mówi, czego brakuje ─────────────────────────
def test_shows_what_is_missing():
assert 'id="readiness"' in TPL
assert "rdy-missing" in JS
for hint in ("Interpretacje", "Kalendarz"):
assert hint in JS, f"brak podpowiedzi, gdzie uzupełnić: {hint}"
def test_all_three_parts_are_checked():
for label in ("Policzony horoskop", "Interpretacja natalna", "Predykcje okresowe"):
assert label in JS
# ───────────────────────────── natalna zapamiętywana ─────────────────────
def test_natal_is_captured_on_interpret_tab():
assert "natal.js" in INTERPRET
assert 'id="natalNote"' in INTERPRET
def test_natal_store_keeps_a_single_entry():
"""Natalna jest JEDNA — dotyczy momentu urodzenia, nie okresu. Ponowne
wygenerowanie ma podmienić, nie dokładać."""
assert "setItem" in NATAL_JS and "push" not in NATAL_JS
def test_natal_only_stores_natal_profile():
assert "'natal'" in NATAL_JS
def test_full_storage_is_reported():
assert "pełny" in NATAL_JS
# ─────────────────────────────── bezpieczeństwo ──────────────────────────
def test_ai_text_is_escaped_before_injection():
"""Treść od modelu wstawiamy do DOM — bez ucieczki byłby to wektor wstrzyknięcia."""
assert "function esc" in JS
assert "replace(/</g, '&lt;')" in JS
def test_does_nothing_on_other_tabs():
assert "if (!document.getElementById('readiness')) return;" in JS
# ─────── podsumowanie NIE gubi opcji policzonych przy horoskopie ──────────
# Regresja 2026-07-28: „Skompiluj" przeliczało horoskop bez stacji/tabel/
# porównania domów, więc podsumowanie miało braki względem tego, co policzono.
def test_compile_form_carries_the_options():
"""Te same opcje co „Horoskop" — z WSPÓLNEGO pliku (formsync je synchronizuje)."""
assert '{% include "_form_options.html" %}' in TPL
opts = (APP / "templates" / "_form_options.html").read_text(encoding="utf-8")
for f in ('name="stations"', 'name="tables"', 'name="house_systems"'):
assert f in opts, f"brak opcji we wspólnym pliku: {f}"
def test_compile_handler_recomputes_with_all_options(monkeypatch):
"""„Skompiluj" przelicza horoskop z KOMPLETEM opcji z formularza.
To jest ta regresja z 2026-07-28: podsumowanie liczyło się z okrojonym
zestawem i miało braki względem tego, co widać na „Horoskopie". Sprawdzamy
przez wywołanie trasy, nie przez szukanie napisów w main.py."""
seen = {}
from app.main import app, logic
monkeypatch.setattr(logic, "positions", lambda **kw: (seen.update(kw), _pdf_sample_chart())[1])
r = _client().post("/compile", data={
"date": "1984-04-30", "time": "11:20", "tz_offset": "2",
"lat": "50.06", "lon": "19.94", "house_system": "koch",
"house_systems": ["koch", "equal"], "stations": "true", "tables": "true",
"zodiac": "sidereal_lahiri", "aspect_minor": "true"})
assert r.status_code == 200, r.text[:300]
assert seen["house_systems"] == ["koch", "equal"]
assert seen["stations"] is True and seen["tables"] is True
assert seen["zodiac"] == "sidereal_lahiri" and seen["aspect_minor"] is True
def test_compile_pdf_recomputes_with_all_options():
assert 'stations=bool(data.get("stations"))' in MAIN
assert 'house_systems=[s for s in (data.get("house_systems")' in MAIN
def test_compile_pdf_payload_includes_options():
assert "house_systems: multi('house_systems')" in JS
assert "stations: checked('stations')" in JS
def test_summary_uses_shared_result_tables():
"""Podsumowanie pokazuje te same tabele co horoskop — jeden wspólny plik,
żeby nie mogły znowu się rozjechać."""
assert '{% include "_result_tables.html" %}' in TPL