b55cab06ca
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m54s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m49s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 32s
Testy / Kontrola składni wszystkich warstw (push) Successful in 18s
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>
249 lines
9.2 KiB
Python
249 lines
9.2 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 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) == ""
|