"""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