998c83b26e
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 11m9s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m59s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 39s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 18s
build-render / build (push) Successful in 8m52s
build / build (push) Successful in 7m9s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 13m40s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 10m3s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 36s
Testy / Kontrola składni wszystkich warstw (push) Successful in 28s
Dwie realne dziury w markdown→LaTeX, obie WIDOCZNE w gotowym PDF:
1. `***mocne***` (pogrubienie+kursywa) łapało się jako `**` + zgubiona gwiazdka →
`\textbf{*mocne}*`, czyli w druku zostawał wisior `*`. Dokładam alternatywę
`\*\*\*…\*\*\*` PRZED `**` i `*` (kolejność od najdłuższego znacznika) →
`\textbf{\textit{…}}`.
2. Nagłówek bez spacji po kratkach (`##Tytuł`), zamknięty ATX (`## Tytuł ##`)
i 7+ kratek trafiały do akapitu i były eskejpowane jako `\#\#…`. Pułapka: w
LaTeXu `\#` renderuje się jako `#`, więc w PDF WIDAĆ było `##`, choć w źródle
`.tex` jest `\#\#`. Dlatego stary test (`"##" not in out`) tego nie łapał —
podłańcuch `##` nie występuje w `\#\#`. Nagłówki traktujemy teraz pobłażliwie:
dowolna liczba kratek na starcie, spacja nieobowiązkowa, końcowe kratki
ucinane, gołe kratki bez treści pomijane. Żaden znacznik nagłówka nie ostaje.
Nowa asercja w testach patrzy pod kątem RENDERU: brak `\#` i brak wiszącej
gwiazdki (poza gwiazdką poleceń `\section*` itd.). +7 testów regresji, render 34.
UWAGA DEPLOY: obraz render w rejestrze to wciąż 56131b20 (sprzed markdown, LOG-27),
bo build-render (TeX Live) padał na runnerze z „no space left on device". Ten PR
dotyka services/render/**, więc powinien wywołać build — ale najpierw trzeba
zwolnić miejsce na runnerze, inaczej i ten build padnie.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
273 lines
9.8 KiB
Python
273 lines
9.8 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")
|
||
|
||
|
||
# ─────────────────── 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))
|