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