Files
astrololo/services/render/tests/test_latex.py
T
gitea 7266f671a4
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m30s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m28s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 17s
Testy / Kontrola składni wszystkich warstw (push) Successful in 8s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m28s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m28s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 18s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 8s
feat(domy): Placidus i Koch + jawny fallback poza kołem podbiegunowym
Etap 2: dwa systemy łuku dobowego — jedyne, które mają miejsca, gdzie po prostu
NIE ISTNIEJĄ. Koch zgadza się z wyrocznią co do zera; Placidus do 1,7e-6°, przy
czym to próg zbieżności WYROCZNI, nie nasz: nasze cuspy spełniają definicję
Placidusa z dokładnością 1e-12° (osobny test, działa bez swissepha).

Placidus jako jedyny nie ma wzoru zamkniętego — cusp jest zdefiniowany warunkiem
na samego siebie („punkt, który przebył 1/3 swojego półłuku"), więc iterujemy po
punkcie stałym do 1e-11°. Brak zbieżności traktujemy jako wyjście poza dziedzinę,
nie jako wynik.

Koch okazał się natomiast ZAMKNIĘTY: jego kryterium to czas od wschodu stopnia
stojącego na MC, a półłuk tego stopnia znamy wprost z jego deklinacji. Definicja
za Astrodienst (astro.com/astrowiki/en/Koch_House_System) — nie zgadywana.

Powyżej koła podbiegunowego oba odmawiają liczenia i wyrocznia odmawia dokładnie
tych samych przypadków (5245/20 000 losowych, zero rozjazdów dziedziny). Odmowa
jest wyjątkiem, nie liczbą: cicha podmiana systemu jest niewykrywalna z wykresu.

Ustępstwo wobec rzeczywistości siedzi osobno, w cusps_detailed(): podstawia
Porphyry'ego i ZAWSZE zostawia ślad. Ten ślad idzie wszystkimi trzema wyjściami —
na ekran (ramka, nie „muted"), w prompt do modelu (inaczej napisze „Twój Placidus"
o Porphyrym) i do PDF-a, w ramce PRZED rysunkami. Astrolog z Tromsø dostaje
wynik i wie, że go dostał inaczej.

Przy okazji: nazwy systemów były zaszyte w czterech szablonach naraz. Przy trzech
systemach uchodziło to na sucho, przy dziesięciu nie — jest katalog w jednym
miejscu, a testy szablonów RENDERUJĄ je zamiast szukać tekstu w źródle, więc
łapią też literówki w Jinja.

Większe jądro efemeryd (de441): świadomie zdegradowane do „nice to have" —
rozszerza wyłącznie zakres dat, nie poprawia niczego w obecnym. Odnotowane
w domain.py przy JD_MIN/JD_MAX, żeby nie wróciło po cichu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 22:04:33 +02:00

331 lines
12 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Generowanie źródła LaTeX raportu (PRE-24).
Najważniejsza część to UCIECZKA znaków specjalnych. Kompilacji tu nie
uruchomimy (brak TeX Live w tym środowisku), ale to i tak nie ona jest tu
najbardziej krucha: błędna ucieczka nie wywala kompilacji, tylko po cichu psuje
treść — najgorszy możliwy wynik, bo PDF powstaje i wygląda poprawnie.
"""
import re
import pytest
from app.latex import build, esc
# ─────────────────────────── ucieczka znaków specjalnych ────────────────
def test_percent_is_escaped():
"""NAJGROŹNIEJSZY przypadek: `%` bez ucieczki komentuje resztę linii, więc
zdanie urywa się w środku, a PDF powstaje normalnie — tylko krótszy."""
out = esc("wzrost o 50% w tym okresie")
assert r"50\%" in out
assert "w tym okresie" in out
def test_backslash_is_escaped_first():
"""Odwrotny ukośnik musi iść pierwszy, inaczej zepsulibyśmy ucieczki
wstawione później (np. `\\%` zamieniłoby się w coś innego)."""
assert esc("a\\b") == r"a\textbackslash{}b"
def test_all_specials_are_escaped():
for ch in ("$", "&", "#", "_", "%", "{", "}"):
assert ch not in esc(ch).replace("\\" + ch, ""), f"{ch} nie zostało uciec­zone"
def test_tilde_and_caret_use_commands():
assert esc("~") == r"\textasciitilde{}"
assert esc("^") == r"\textasciicircum{}"
def test_polish_and_glyphs_pass_through():
"""XeLaTeX bierze unicode wprost — polskich znaków ani glifów nie ruszamy."""
text = "Zażółć gęślą jaźń ♄ ♓ ☉"
assert esc(text) == text
def test_escape_handles_none_and_numbers():
assert esc(None) == ""
assert esc(42) == "42"
# ─────────────────────────────── układ dokumentu ─────────────────────────
def _report(**over):
base = {
"person": "Jan Kowalski",
"data": {"date": "1984-04-30", "time": "11:20", "tz_offset": 2,
"lat": 50.0647, "lon": 19.945, "place": "Kraków",
"house_system": "whole_sign", "zodiac": "tropical"},
"natal": {"text": "Interpretacja natalna.\n\nDrugi akapit."},
"predictions": [
{"from_date": "2026-01-01", "to_date": "2026-03-31", "text": "Pierwszy kwartał."},
{"from_date": "2026-07-01", "to_date": "2026-09-30", "text": "Trzeci kwartał."},
],
}
base.update(over)
return base
def test_document_is_complete():
tex = build(_report())
assert tex.startswith(r"\documentclass")
assert tex.rstrip().endswith(r"\end{document}")
def test_uses_unicode_engine_setup():
"""XeLaTeX + fontspec — bez tego polskie znaki i glify nie wyjdą."""
tex = build(_report())
assert r"\usepackage{fontspec}" in tex
assert r"\setmainfont" in tex
def test_section_order_matches_the_brief():
"""Prośba partnerów: imię → dane → rysunek → natalna → predykcje."""
tex = build(_report(), wheel_pdf="wheel.pdf")
person = tex.index("Jan Kowalski")
data = tex.index("Data urodzenia")
wheel = tex.index("includegraphics")
natal = tex.index("Interpretacja natalna")
preds = tex.index("Predykcje okresowe")
assert person < data < wheel < natal < preds
def test_person_name_opens_the_document():
tex = build(_report())
body = tex.split(r"\begin{document}")[1]
assert body.index("Jan Kowalski") < 80, "imię ma być na samym początku"
def test_all_predictions_are_included():
tex = build(_report())
assert "2026-01-01" in tex and "2026-07-01" in tex
assert "Pierwszy kwartał" in tex and "Trzeci kwartał" in tex
def test_paragraphs_are_preserved():
"""Model oddziela akapity pustą linią — mają zostać akapitami, nie zlepkiem."""
tex = build(_report())
assert "Interpretacja natalna." in tex and "Drugi akapit." in tex
assert "Interpretacja natalna.\n\nDrugi akapit." in tex
def test_hostile_text_cannot_break_the_document():
"""Tekst od modelu jest wejściem z ZEWNĄTRZ — nie może wstrzyknąć polecenia
ani urwać dokumentu."""
nasty = r"100% \end{document} \input{/etc/passwd} $x_1$ #& {}"
tex = build(_report(natal={"text": nasty}))
body = tex.split(r"\begin{document}")[1]
assert body.count(r"\end{document}") == 1, "tekst urwał dokument"
assert r"\input{" not in body
def test_missing_parts_are_simply_absent():
"""Niekompletny materiał ma dać krótszy raport, nie wyjątek."""
tex = build({"person": "", "data": {}})
assert r"\begin{document}" in tex and r"\end{document}" in tex
assert "Raport astrologiczny" in tex # zapas, gdy brak imienia
def test_wheel_is_optional():
assert "includegraphics" not in build(_report())
assert "includegraphics" in build(_report(), wheel_pdf="wheel.pdf")
# ─────────── wiele rysunków w raporcie (koło + aspektarian + …) ───────────
def test_all_figures_are_embedded_in_order():
"""Co pokazujemy na stronie, ma trafić do PDF-a. Rysunki idą w PODANEJ
kolejności — koło, potem aspektarian, deklinacja, antyscja."""
figs = [
{"pdf": "fig0.pdf", "caption": "Kosmogram"},
{"pdf": "fig1.pdf", "caption": "Aspektarian"},
{"pdf": "fig2.pdf", "caption": "Wykres deklinacji"},
]
tex = build(_report(), figures=figs)
assert tex.count("includegraphics") == 3
assert tex.index("fig0.pdf") < tex.index("fig1.pdf") < tex.index("fig2.pdf")
def test_figure_captions_are_printed_and_escaped():
tex = build(_report(), figures=[{"pdf": "fig0.pdf", "caption": "Deklinacja 50%"}])
assert "Deklinacja 50\\%" in tex # podpis eskejpowany jak reszta
def test_figures_sit_between_data_and_natal():
"""Rysunki nadal w sekcji 3 — po danych, przed interpretacją natalną."""
tex = build(_report(), figures=[{"pdf": "fig0.pdf", "caption": "Kosmogram"}])
assert tex.index("Data urodzenia") < tex.index("fig0.pdf") < tex.index("Interpretacja natalna")
def test_wheel_pdf_still_works_as_single_figure():
"""Zgodność wsteczna: pojedynczy wheel_pdf = jeden rysunek."""
tex = build(_report(), wheel_pdf="wheel.pdf")
assert tex.count("includegraphics") == 1 and "wheel.pdf" in tex
def test_figure_without_pdf_is_skipped():
tex = build(_report(), figures=[{"caption": "brak pliku"}, {"pdf": "fig1.pdf"}])
assert tex.count("includegraphics") == 1 and "fig1.pdf" in tex
# ─────────────────── Markdown → LaTeX (nie surowy copy-paste) ─────────────
from app.latex import markdown_to_latex as md
def test_bold_and_italic_become_commands():
out = md("To **ważne** i *podkreślone*.")
assert r"\textbf{ważne}" in out
assert r"\textit{podkreślone}" in out
assert "**" not in out and "*" not in out # znaczniki znikają
def test_headings_are_demoted_under_our_section():
"""Markdownowe nagłówki idą POD nasz `\\section*` (natalna/predykcje):
# → subsection, ## → subsubsection, ### i głębsze → paragraph."""
assert r"\subsection*{Tytuł}" in md("# Tytuł")
assert r"\subsubsection*{Podtytuł}" in md("## Podtytuł")
assert r"\paragraph*{Głębiej}" in md("### Głębiej")
assert "#" not in md("## Podtytuł") # kratki znikają
def test_bullet_list_becomes_itemize():
out = md("- pierwszy\n- drugi\n- trzeci")
assert r"\begin{itemize}" in out and r"\end{itemize}" in out
assert out.count(r"\item ") == 3
def test_numbered_list_becomes_enumerate():
out = md("1. raz\n2. dwa")
assert r"\begin{enumerate}" in out and r"\end{enumerate}" in out
assert out.count(r"\item ") == 2
def test_switching_list_type_closes_the_previous():
"""Punktory po numerach nie mogą wpaść do jednego środowiska."""
out = md("1. numer\n- punkt")
assert r"\end{enumerate}" in out and r"\begin{itemize}" in out
def test_inline_code_becomes_texttt():
assert r"\texttt{kod}" in md("użyj `kod` tutaj")
def test_link_keeps_text_drops_url():
out = md("zobacz [stronę](https://example.com/x)")
assert "stronę" in out
assert "example.com" not in out and "http" not in out
def test_paragraphs_are_separated():
out = md("Pierwszy akapit.\n\nDrugi akapit.")
assert "Pierwszy akapit." in out and "Drugi akapit." in out
assert out.count("\n\n") >= 1
def test_special_chars_inside_formatting_are_escaped():
"""Ucieczka LaTeXa działa TAKŻE wewnątrz pogrubienia — inaczej `%` w bold
zakomentowałby resztę linii."""
out = md("**wzrost 50% & więcej**")
assert r"\textbf{wzrost 50\% \& więcej}" in out
def test_no_markdown_markers_leak_through():
"""Żaden surowy znacznik markdown nie ma trafić do PDF."""
out = md("## Tytuł\n\n**b** i *i* oraz `c`\n\n- lista")
for marker in ("##", "**", "`"):
assert marker not in out
def test_bold_before_italic_not_two_italics():
"""`**x**` to pogrubienie, nie dwie kursywy — kolejność w regexie."""
out = md("**mocno**")
assert r"\textbf{mocno}" in out
assert r"\textit{" not in out
def test_empty_and_none_are_safe():
assert md("") == ""
assert md(None) == ""
# ───── regresja: znaczniki, które WIDAĆ w PDF mimo ucieczki (## i ***) ─────
# `\#` renderuje się jako `#`, a zgubiona gwiazdka zostaje `*`. Test na surowym
# `.tex` sprawdzający tylko podłańcuch „##" tego NIE łapie (w źródle jest `\#\#`),
# dlatego pilnujemy wprost: żadnego `\#` i żadnej wiszącej gwiazdki.
def _no_visible_markers(out: str) -> bool:
"""Czy w wyrenderowanym PDF NIE będzie widać znaczników markdown?
`\\#` wyszłoby jako `#`; jedyne dozwolone gwiazdki to część poleceń
`\\section*`/`\\subsection*`/`\\subsubsection*`/`\\paragraph*`."""
if r"\#" in out:
return False
stripped = re.sub(r"\\(?:sub)*section\*|\\paragraph\*", "", out)
return "*" not in stripped
def test_triple_asterisk_becomes_bold_italic():
"""`***tekst***` = pogrubienie + kursywa, bez wiszących gwiazdek."""
out = md("To ***bardzo mocne*** słowo.")
assert r"\textbf{\textit{bardzo mocne}}" in out
assert _no_visible_markers(out)
def test_triple_asterisk_in_list_and_midline():
for src in ("- punkt z ***naciskiem***", "przed ***X*** po"):
assert _no_visible_markers(md(src)), src
def test_heading_without_space_is_converted_not_leaked():
"""`##Bez spacji` też ma zostać nagłówkiem — inaczej `\\#\\#` daje `##` w PDF."""
out = md("##Bez spacji")
assert r"\subsubsection*{Bez spacji}" in out
assert _no_visible_markers(out)
def test_closed_atx_heading_drops_trailing_hashes():
out = md("### Zamknięty ##")
assert r"\paragraph*{Zamknięty}" in out
assert _no_visible_markers(out)
def test_more_than_six_hashes_still_no_hash_leak():
out = md("####### siedem kratek")
assert _no_visible_markers(out)
assert "siedem kratek" in out
def test_bare_hashes_line_is_dropped():
assert md("###") == ""
def test_full_ai_markdown_leaves_no_visible_markers():
"""Realny miks od modelu: nagłówki (ze spacją i bez), potrójne gwiazdki,
zamknięty ATX — nic z tego nie ma być widać w PDF."""
src = ("## Charakter\n\nOsoba ***wybitnie*** wrażliwa.\n\n"
"###Podsekcja bez spacji\n\n- punkt **ważny**\n- i ***kluczowy***\n\n"
"#### Zamknięty ####")
assert _no_visible_markers(md(src))
def test_house_fallback_warning_lands_in_the_pdf():
"""Zasada projektu: co pokazujemy na stronie, trafia do raportu i PDF-a.
Po fallbacku systemu domów kosmogram wygląda bezbłędnie, więc ten akapit
jest JEDYNĄ informacją, że podział domów pochodzi z innego systemu, niż
zamówiono. Musi też stać PRZED rysunkami, nie po nich."""
tex = build({"person": "Jan Kowalski",
"data": {"date": "1984-04-30", "house_system": "porphyry"},
"warnings": ["UWAGA: system domów „placidus” nie ma definicji"]},
figures=[{"pdf": "wheel.pdf", "caption": "Kosmogram"}])
assert "fbox" in tex
assert "nie ma definicji" in tex
assert tex.index("nie ma definicji") < tex.index("wheel.pdf"), \
"ostrzeżenie musi stać przed rysunkami"
def test_no_warning_box_when_nothing_was_substituted():
tex = build({"person": "Jan Kowalski", "data": {}, "warnings": []})
assert "fbox" not in tex