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
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>
331 lines
12 KiB
Python
331 lines
12 KiB
Python
"""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 ucieczone"
|
||
|
||
|
||
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
|