Files
astrololo/services/render/tests/test_latex.py
T
gitea f5dec15e4d
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 11m13s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m49s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 29s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 20s
build-render / build (push) Successful in 44s
build / build (push) Successful in 54s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m19s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m59s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 34s
Testy / Kontrola składni wszystkich warstw (push) Successful in 25s
feat: aspektarian, deklinacja i antyscja trafiają do raportu i PDF-a
Zasada: co składamy, dodajemy i wyświetlamy, MA trafiać do finalnego raportu
(«Skompiluj») i do PDF-a — nie tylko na stronę /chart. Do tej pory do raportu i
druku szło samo koło; aspektarian (etap 5) i deklinacja/antyscja (etap 6) były
tylko w podglądzie horoskopu.

RAPORT «Skompiluj» (podgląd): handler /compile liczy teraz wszystkie cztery
rysunki, a szablon je pokazuje (koło, aspektarian, deklinacja, antyscja).

PDF (usługa render) — uogólnienie z jednego rysunku na LISTĘ:
- Kontrakt raportu: `figures = [{svg, caption}]` (uporządkowana). `wheel_svg`
  zostaje dla zgodności wstecznej jako pojedynczy rysunek.
- `compile.py`: każdy SVG osobno przez rsvg-convert → PDF (fig0…figN); braki/
  błędy pomijane (rysunek nie może wywalić raportu, tekst ważniejszy).
- `latex.py build()`: rysunki w sekcji 3 (po danych, przed natalną) w PODANEJ
  kolejności, każdy z podpisem. Jedna reguła składania: keepaspectratio z limitem
  szerokości i wysokości — kwadratowe (koło, aspektarian) ogranicza wysokość,
  szerokie (deklinacja, antyscja) szerokość, bez zniekształceń.
- `compile_pdf` (prezentacja): renderuje komplet w motywie DRUKU i wysyła jako
  `figures`.

Testy: render +5 (kolejność, podpisy, zgodność wsteczna, pomijanie pustych),
prezentacja +3 (raport pokazuje komplet, PDF składa komplet). Render 32,
prezentacja 173.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 20:42:23 +02:00

310 lines
11 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))