feat(render): raport PDF przez LaTeX jako osobna usluga (PRE-24)
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m4s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m57s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 34s
Testy / Kontrola składni wszystkich warstw (push) Successful in 22s

Ostatnia z osmiu wskazowek partnerow. Nowa usluga services/render sklada raport:
generuje plik posredni .tex i kompiluje go XeLaTeX-em do PDF.

OSOBNY komponent, nie czesc prezentacji — ta sama zasada co przy izolacji
swissepha (LOG-27). TeX Live wazy setki megabajtow; w obrazie produktu
spowalnialby kazdy build, a tak aktualizuje sie niezaleznie i jego awaria nie
kladzie aplikacji, tylko przycisk „Pobierz PDF".

SZYFROWANIE, o ktore prosiles: lacze prezentacja↔render idzie tak samo jak
pozostale, ale z WLASNYM, TRZECIM kluczem (LINK_KEY_PRESENTATION_RENDER). Osobny,
bo tym laczem plynie CALY raport — dane urodzeniowe i opisy z baz — wiec przejecie
go nie moze otwierac lacza do logiki ani do danych. Fail-closed: bez klucza pod
nie wstaje. Test kopii link_crypto obejmuje teraz cztery uslugi.

Uklad PDF wg prosby: imie i nazwisko -> wprowadzone dane -> RYSUNEK kosmogramu
-> interpretacja natalna -> predykcje okresowe. Test pilnuje kolejnosci.

Dwie rzeczy, ktore wyszly dopiero przy skladaniu tego kawalka:

1. KOSMOGRAM NIE NADAWAL SIE DO PDF. Uzywa zmiennych CSS (var(--line)) i klasy
   .glyph, ktorej font podaje styles.css — a samodzielny konwerter SVG→PDF nie zna
   naszego arkusza. Wyszlyby czarne kreski BEZ SYMBOLI. Stad wariant „print":
   konkretne kolory na bialym tle i font glifow wpisany wprost w rysunek. Przy
   okazji zalatwia to etap 4 planu kola (wariant do druku).

2. UCIECZKA ZNAKOW LATEXA byla zepsuta — i zlapal to moj wlasny test. Zamiana
   „\” na \textbackslash{} szla w tej samej petli co nawiasy, wiec kolejne
   podmiany ucieklyby nawiasy dopiero co wstawione: wychodzilo
   \textbackslash\{\}. Poprawka: ukosnik chowany pod znacznik zastepczy i
   rozwijany na koncu. To nie kosmetyka — niezauwazony „%” komentuje RESZTE LINII,
   wiec zdanie od modelu urywaloby sie w polowie, a PDF powstawalby normalnie,
   tylko krotszy. Test na wrogim tekscie sprawdza tez, ze \end{document} ani
   \input{} nie wyrwa sie z dokumentu.

Usluga nie zapisuje nic poza katalogiem tymczasowym, ktory sprzata po sobie;
w manifescie readOnlyRootFilesystem + emptyDir na /tmp. /health raportuje
obecnosc xelatex i rsvg-convert, zeby zepsuty obraz bylo widac od razu.

Zweryfikowane na zywo (TestClient uslugi render): zadanie bez szyfrowania
z POPRAWNYM tokenem -> 400; obcy klucz -> 400 i tajny opis nie wraca; wlasciwy
klucz -> zadanie dochodzi do aplikacji, tresci baz NIE MA na kablu, odpowiedz
zaszyfrowana. Manifesty przechodza kubectl apply --dry-run=server na zywym
klastrze.

CZEGO NIE SPRAWDZILEM: samej kompilacji PDF. W tym srodowisku nie ma ani TeX
Live, ani dzialajacego runtime'u kontenerow (docker CLI jest, daemon nie) —
probowalem zbudowac obraz i sie nie dalo. Sprawdzone jest wszystko dookola:
generowanie .tex, ucieczka znakow, szyfrowanie, kontrakt API, samowystarczalnosc
SVG. Pierwsze uruchomienie na klastrze trzeba obejrzec — dlatego PRE-24 zostaje
jako „W trakcie", nie „Zrobione".

Instrukcja wdrozenia: docs/wdrozenie-render-pdf.md (klucz, obraz, merge,
weryfikacja, znane ograniczenia).

Testy: 15 nowych (render) + 14 (lacze i wariant druku w prezentacji).
Calosc: prezentacja 131, logika 265/1 skip, render 15.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 19:21:49 +02:00
parent 6c9029c496
commit f24616d342
22 changed files with 1546 additions and 22 deletions
+53 -19
View File
@@ -49,6 +49,22 @@ MIN_SEP = 8.0
# muted kolory żywiołów — czytelne na ciemnym tle, spójne z paletą aplikacji
_ELEMENT_COLOR = ["#d98a6a", "#8fae7a", "#cfc06a", "#6aa6c9"] # ogień, ziemia, powietrze, woda
# ── motywy ───────────────────────────────────────────────────────────────
# „screen" korzysta ze zmiennych CSS strony, więc koło samo dostraja się do
# motywu aplikacji. „print" MUSI mieć konkretne kolory: SVG idzie wtedy do
# osobnego konwertera (rsvg → PDF, PRE-24), który o naszym arkuszu nic nie wie
# i zmiennych `var(--…)` nie rozwiąże — wyszłyby czarne albo żadne kreski.
# Przy okazji odwracamy kontrast: raport drukuje się na białym papierze.
_THEMES = {
"screen": {"line": "var(--line)", "ink": "var(--ink)", "muted": "var(--muted)",
"accent": "var(--accent)", "disc": "#12132a", "retro": "#ff9b6a",
"elements": _ELEMENT_COLOR},
"print": {"line": "#b9bcc9", "ink": "#111318", "muted": "#5d6270",
"accent": "#5a49c8", "disc": "#ffffff", "retro": "#b2452a",
# ciemniejsze warianty żywiołów — na białym tle jasne byłyby niewidoczne
"elements": ["#a8442a", "#3f6b33", "#8a6d10", "#1f5c80"]},
}
def _pt(lon: float, asc: float, r: float) -> tuple[float, float]:
phi = math.radians(180.0 - (lon - asc))
@@ -61,10 +77,20 @@ def _line(x1: float, y1: float, x2: float, y2: float, stroke: str, w: float = 1.
f'stroke="{stroke}" stroke-width="{w}" opacity="{opacity}"/>')
def _text(x: float, y: float, s: str, *, size: float, fill: str, cls: str = "") -> str:
# Stack fontów z glifami astrologicznymi. Na stronie dostarcza go klasa CSS
# `.glyph`, ale w wariancie do druku SVG jedzie do SAMODZIELNEGO konwertera, który
# naszego arkusza nie widzi — wtedy trzeba wpisać font wprost do elementu, inaczej
# rysunek wychodzi BEZ symboli (konwerter bierze domyślny font, a ten ich nie ma).
GLYPH_FONT = ("Apple Symbols, Segoe UI Symbol, Noto Sans Symbols2, "
"DejaVu Sans, sans-serif")
def _text(x: float, y: float, s: str, *, size: float, fill: str, cls: str = "",
font: str = "") -> str:
c = f' class="{cls}"' if cls else ""
f = f' font-family="{font}"' if font else ""
return (f'<text x="{x:.2f}" y="{y:.2f}" font-size="{size}" fill="{fill}"'
f' text-anchor="middle" dominant-baseline="central"{c}>{escape(s)}</text>')
f' text-anchor="middle" dominant-baseline="central"{c}{f}>{escape(s)}</text>')
def spread(lons: list[float], min_sep: float = MIN_SEP, passes: int = 300) -> list[float]:
@@ -126,8 +152,16 @@ def _delta(a: float, b: float) -> float:
return abs(((a - b + 180.0) % 360.0) - 180.0)
def render(chart: dict) -> str:
"""Zwraca SVG koła albo '' gdy brakuje danych (silnik bez osi/domów)."""
def render(chart: dict, theme: str = "screen") -> str:
"""Zwraca SVG koła albo '' gdy brakuje danych (silnik bez osi/domów).
theme='screen' — kolory ze zmiennych CSS aplikacji (dostraja się do motywu).
theme='print' — konkretne kolory na białym tle; konieczne, gdy SVG trafia
do samodzielnego konwertera (PDF), który zmiennych CSS nie rozwiąże.
"""
T = _THEMES.get(theme, _THEMES["screen"])
# tylko dla druku — na stronie font podaje CSS (.glyph)
glyph_font = GLYPH_FONT if theme == "print" else ""
angles = chart.get("angles")
cusps = chart.get("cusps")
signs = chart.get("sign_glyphs")
@@ -138,26 +172,26 @@ def render(chart: dict) -> str:
parts: list[str] = []
# tło i okręgi
parts.append(f'<circle cx="{_CX}" cy="{_CY}" r="{R_OUT}" fill="#12132a" stroke="var(--line)" stroke-width="1.5"/>')
parts.append(f'<circle cx="{_CX}" cy="{_CY}" r="{R_ZOD}" fill="none" stroke="var(--line)" stroke-width="1"/>')
parts.append(f'<circle cx="{_CX}" cy="{_CY}" r="{R_HUB}" fill="none" stroke="var(--line)" stroke-width="1"/>')
parts.append(f'<circle cx="{_CX}" cy="{_CY}" r="{R_OUT}" fill="{T["disc"]}" stroke="{T["line"]}" stroke-width="1.5"/>')
parts.append(f'<circle cx="{_CX}" cy="{_CY}" r="{R_ZOD}" fill="none" stroke="{T["line"]}" stroke-width="1"/>')
parts.append(f'<circle cx="{_CX}" cy="{_CY}" r="{R_HUB}" fill="none" stroke="{T["line"]}" stroke-width="1"/>')
# drobne podziałki co 5°, mocniejsze co 10° (na pasie znaków)
for deg in range(0, 360, 5):
r_in = R_TICK if deg % 10 else R_TICK - 4
x1, y1 = _pt(deg, asc, R_ZOD)
x2, y2 = _pt(deg, asc, r_in)
parts.append(_line(x1, y1, x2, y2, "var(--line)", 0.6, 0.7))
parts.append(_line(x1, y1, x2, y2, T["line"], 0.6, 0.7))
# pas znaków: granice co 30° + glif znaku w środku sektora, kolorem żywiołu
for i, sg in enumerate(signs):
b = 30.0 * i
x1, y1 = _pt(b, asc, R_ZOD)
x2, y2 = _pt(b, asc, R_OUT)
parts.append(_line(x1, y1, x2, y2, "var(--line)", 1.0))
parts.append(_line(x1, y1, x2, y2, T["line"], 1.0))
gx, gy = _pt(b + 15.0, asc, R_SIGN)
parts.append(_text(gx, gy, sg.get("glyph") or "", size=17,
fill=_ELEMENT_COLOR[i % 4], cls="glyph"))
fill=T["elements"][i % 4], cls="glyph", font=glyph_font))
# szprychy domów (od pasa do piasty) + numery domów w środku każdego domu
n = len(cusps)
@@ -165,24 +199,24 @@ def render(chart: dict) -> str:
lon = float(c["decimal"])
x1, y1 = _pt(lon, asc, R_ZOD)
x2, y2 = _pt(lon, asc, R_HUB)
parts.append(_line(x1, y1, x2, y2, "var(--line)", 0.8, 0.85))
parts.append(_line(x1, y1, x2, y2, T["line"], 0.8, 0.85))
nxt = float(cusps[(i + 1) % n]["decimal"])
span = (nxt - lon) % 360.0 or 360.0
mid = lon + span / 2.0
hx, hy = _pt(mid, asc, R_HNUM)
parts.append(_text(hx, hy, str(c["house"]), size=11, fill="var(--muted)"))
parts.append(_text(hx, hy, str(c["house"]), size=11, fill=T["muted"]))
# osie: AscDsc i MCIC przez całe koło, wyróżnione akcentem
for a, b, la, lb in (("Asc", "Dsc", "AC", "DC"), ("MC", "IC", "MC", "IC")):
lon_a = float(angles[a]["decimal"])
ax, ay = _pt(lon_a, asc, R_ZOD)
bx, by = _pt(lon_a + 180.0, asc, R_ZOD)
parts.append(_line(ax, ay, bx, by, "var(--accent)", 1.6, 0.9))
parts.append(_line(ax, ay, bx, by, T["accent"], 1.6, 0.9))
# etykiety POZA kołem — w środku kolidowały z glifami obiektów
lax, lay = _pt(lon_a, asc, R_AXIS_LABEL)
lbx, lby = _pt(lon_a + 180.0, asc, R_AXIS_LABEL)
parts.append(_text(lax, lay, la, size=10, fill="var(--accent)"))
parts.append(_text(lbx, lby, lb, size=10, fill="var(--accent)"))
parts.append(_text(lax, lay, la, size=10, fill=T["accent"]))
parts.append(_text(lbx, lby, lb, size=10, fill=T["accent"]))
# ── obiekty: glify na pozycjach, stopień w znaku, retrogradacja ─────
# Glify rozsuwamy, ale kreska na pasie zostaje w PRAWDZIWEJ pozycji —
@@ -197,15 +231,15 @@ def render(chart: dict) -> str:
mx1, my1 = _pt(true_lon, asc, R_ZOD)
mx2, my2 = _pt(true_lon, asc, R_MARK)
parts.append(_line(mx1, my1, mx2, my2, "var(--ink)", 1.0, 0.75))
parts.append(_line(mx1, my1, mx2, my2, T["ink"], 1.0, 0.75))
if _delta(draw_lon, true_lon) > 0.4:
lx, ly = _pt(draw_lon, asc, R_PLANET + 12)
parts.append(_line(mx2, my2, lx, ly, "var(--line)", 0.7, 0.9))
parts.append(_line(mx2, my2, lx, ly, T["line"], 0.7, 0.9))
gx, gy = _pt(draw_lon, asc, R_PLANET)
parts.append(_text(gx, gy, obj.get("glyph") or "", size=16,
fill="var(--ink)", cls="glyph"))
fill=T["ink"], cls="glyph", font=glyph_font))
# Sam stopień w znaku, bez „°" — tak robią programy astrologiczne i tylko
# tak podpisy mieszczą się obok siebie w skupiskach. Retrogradacja: ℞ oraz
@@ -213,7 +247,7 @@ def render(chart: dict) -> str:
dx, dy = _pt(draw_lon, asc, R_DEG)
label = f"{int(true_lon % 30)}" + ("" if retro else "")
parts.append(_text(dx, dy, label, size=8,
fill="#ff9b6a" if retro else "var(--muted)"))
fill=T["retro"] if retro else T["muted"]))
body = "".join(parts)
return (f'<svg class="wheel" viewBox="0 0 {_VIEW} {_VIEW}" role="img" '
@@ -0,0 +1,52 @@
"""Klient HTTP do usługi render (PRE-24).
Jedyny punkt styku prezentacji z usługą składającą PDF. Idzie po szyfrowanym
łączu, tak jak reszta ruchu wewnątrz klastra — z WŁASNYM, trzecim kluczem
(`LINK_KEY_PRESENTATION_RENDER`). Osobny klucz, bo przez to łącze leci CAŁY
raport: dane urodzeniowe i opisy z baz. Przejęcie go nie może otwierać łącza do
logiki ani do danych.
"""
from __future__ import annotations
import os
import httpx
from app import link_crypto
from app.config import settings
def _auth_headers() -> dict[str, str]:
"""Token międzywarstwowy (LOG-32) — pusty, gdy ochrona wyłączona."""
token = os.getenv("INTERNAL_TOKEN", "")
return {"X-Astrololo-Token": token} if token else {}
def _link() -> link_crypto.Link | None:
"""Klucz łącza prezentacja↔render. Czytany przy każdym wywołaniu, bo
konfiguracja może się zmienić bez restartu procesu."""
key = link_crypto.key_from_env(link_crypto.ENV_PRESENTATION_RENDER)
return link_crypto.Link(key) if key else None
class RenderClient:
def __init__(self, base_url: str | None = None) -> None:
self.base_url = (base_url or settings.render_url).rstrip("/")
def pdf(self, report: dict) -> bytes:
"""Raport → bajty PDF. Timeout długi: XeLaTeX na obszernym tekście
potrzebuje chwili, a raport bywa na kilkadziesiąt stron."""
with httpx.Client(timeout=max(settings.http_timeout, 180.0)) as client:
return link_crypto.call(client, "POST", f"{self.base_url}/pdf",
payload=report, headers=_auth_headers(),
link=_link())
def health(self) -> dict:
"""/health celowo poza szyfrowaniem — pukają tu sondy k8s, które klucza
nie mają, a nie przechodzi tędy nic z raportu."""
import json
with httpx.Client(timeout=settings.http_timeout) as client:
r = client.get(f"{self.base_url}/health", headers=_auth_headers())
r.raise_for_status()
return json.loads(r.content)
+2
View File
@@ -11,6 +11,8 @@ from dataclasses import dataclass, field
@dataclass
class Settings:
logic_url: str = field(default_factory=lambda: os.getenv("LOGIC_URL", "http://localhost:8001"))
# Usługa składająca PDF (PRE-24) — osobny komponent, bo dźwiga TeX Live.
render_url: str = field(default_factory=lambda: os.getenv("RENDER_URL", "http://localhost:8004"))
http_timeout: float = field(default_factory=lambda: float(os.getenv("HTTP_TIMEOUT", "10")))
+4
View File
@@ -65,6 +65,10 @@ CONTENT_TYPE = "application/vnd.astrololo.enc"
ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC"
ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA"
# Trzecia para: prezentacja ↔ render (PRE-24). Osobny klucz, jak przy pozostałych —
# usługa render dostaje CAŁY raport (dane urodzeniowe + opisy z baz), więc przejęcie
# jej klucza nie może otwierać łącza do logiki ani do danych.
ENV_PRESENTATION_RENDER = "LINK_KEY_PRESENTATION_RENDER"
ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED"
REQUEST, RESPONSE = b"req", b"res"
+59
View File
@@ -149,6 +149,65 @@ def compile_build(
return templates.TemplateResponse(request, "compile.html", ctx)
@app.post("/compile/pdf")
def compile_pdf(payload: dict):
"""Składa raport PDF (PRE-24) — woła usługę render po szyfrowanym łączu.
Wejście z przeglądarki, bo części od AI (interpretacja natalna i predykcje)
mieszkają w magazynie lokalnym. Kosmogram i tabele liczymy TU, żeby PDF
zawierał dokładnie to, co widać na stronie.
Kosmogram idzie w motywie DRUKU: samodzielny konwerter SVG→PDF nie zna
naszego arkusza, więc zmienne CSS i font glifów muszą być w samym rysunku.
"""
from fastapi.responses import Response
from app.clients.render_client import RenderClient
data = payload.get("data") or {}
try:
iso_utc, label = _build_utc(
str(data.get("date") or ""), str(data.get("time") or ""),
float(data.get("tz_offset") or 0.0),
)
except (ValueError, TypeError) as e:
return JSONResponse({"detail": f"Niepoprawne dane wejściowe: {e}"}, status_code=422)
wheel_svg = ""
try:
chart = logic.positions(
when_utc_iso=iso_utc,
lat=float(data.get("lat") or 0.0), lon=float(data.get("lon") or 0.0),
house_system=str(data.get("house_system") or "whole_sign"),
zodiac=str(data.get("zodiac") or "tropical"),
)
from app import chartwheel
wheel_svg = chartwheel.render(chart, theme="print")
except httpx.HTTPError as e:
# Brak rysunku nie może zablokować raportu — tekst jest ważniejszy.
log_note = _logic_error(e)
chart, wheel_svg = None, ""
data = {**data, "wheel_error": log_note}
report = {
"person": payload.get("person") or "",
"data": {**data, "moment_utc": label},
"wheel_svg": wheel_svg,
"natal": payload.get("natal") or {},
"predictions": payload.get("predictions") or [],
}
try:
pdf = RenderClient().pdf(report)
except httpx.HTTPError as e:
return JSONResponse(
{"detail": f"Usługa render niedostępna albo nie złożyła PDF-a: {e}"},
status_code=502,
)
return Response(content=pdf, media_type="application/pdf",
headers={"Content-Disposition": 'attachment; filename="raport.pdf"'})
# ---------------- Sygnifikatory (wyszukiwarka w bazach) ----------------
@app.get("/significators", response_class=HTMLResponse)
def significators_form(request: Request):
@@ -85,11 +85,75 @@
else document.addEventListener('DOMContentLoaded', fn);
}
// ── PDF (PRE-24) ────────────────────────────────────────────────────
// Części od AI mieszkają w przeglądarce, więc raport składamy TU i wysyłamy
// do serwera, który dokłada kosmogram i woła usługę render.
function formData() {
function v(name) {
var el = document.querySelector('[name=' + name + ']');
return el ? el.value : '';
}
return {
date: v('date'), time: v('time'), tz_offset: v('tz_offset'),
lat: v('lat'), lon: v('lon'),
house_system: v('house_system'), zodiac: v('zodiac'),
place: (document.querySelector('#geoSearch') || {}).value || ''
};
}
function download(blob) {
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'raport.pdf';
document.body.appendChild(a);
a.click();
a.remove();
// Zwalniamy obiekt dopiero po kliknięciu — wcześniej przeglądarka nie zdąży
// rozpocząć pobierania.
setTimeout(function () { URL.revokeObjectURL(url); }, 4000);
}
function buildPdf(btn, note) {
var person = (document.querySelector('[name=person]') || {}).value || '';
var payload = {
person: person,
data: formData(),
natal: readNatal() || {},
predictions: readPredictions()
};
btn.disabled = true;
note.textContent = 'Składam PDF — LaTeX potrzebuje chwili przy obszernym tekście…';
fetch('/compile/pdf', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}).then(function (r) {
if (!r.ok) {
return r.json().catch(function () { return {}; }).then(function (d) {
throw new Error(d.detail || ('HTTP ' + r.status));
});
}
return r.blob();
}).then(function (blob) {
download(blob);
note.textContent = 'Gotowe — PDF pobrany.';
}).catch(function (e) {
note.textContent = 'Nie udało się złożyć PDF-a: ' + e.message;
}).finally(function () {
btn.disabled = false;
});
}
ready(function () {
if (!document.getElementById('readiness')) return; // nie ta zakładka
// Horoskop jest w dokumencie tylko po wysłaniu formularza.
renderReadiness(!!document.getElementById('reportPerson'));
renderNatal();
renderPredictions();
var btn = document.getElementById('pdfBtn');
var note = document.getElementById('pdfNote');
if (btn && note) btn.addEventListener('click', function () { buildPdf(btn, note); });
});
})();
@@ -57,6 +57,9 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t
</div>
<div class="actions">
<button type="submit">Złóż podsumowanie</button>
<button type="button" id="pdfBtn" class="ghost"
title="Generuje plik pośredni LaTeXa i kompiluje go do PDF (usługa render)">Pobierz PDF</button>
<span id="pdfNote" class="muted small"></span>
</div>
</form>
@@ -0,0 +1,149 @@
"""Łącze do usługi render i wariant kosmogramu do druku (PRE-24).
Przez to łącze leci CAŁY raport: dane urodzeniowe i opisy z baz. Musi być
szyfrowane tak samo jak pozostałe, WŁASNYM kluczem — i musi obowiązywać ten sam
niezmiennik co dla klienta logiki: żadnego wyjścia w dół bez tokenu i klucza.
"""
import pathlib
import pytest
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
CLIENT = APP / "clients" / "render_client.py"
SRC = CLIENT.read_text(encoding="utf-8")
WHEEL = (APP / "chartwheel.py").read_text(encoding="utf-8")
MAIN = (APP / "main.py").read_text(encoding="utf-8")
COMPILE_JS = (APP / "static" / "compile.js").read_text(encoding="utf-8")
# ─────────────────────────── szyfrowanie łącza ───────────────────────────
def test_uses_its_own_third_key():
"""Osobny klucz na parę rozmówców. Przejęcie klucza renderu nie może otwierać
łącza do logiki ani do danych."""
from app import link_crypto
assert link_crypto.ENV_PRESENTATION_RENDER == "LINK_KEY_PRESENTATION_RENDER"
assert link_crypto.ENV_PRESENTATION_RENDER != link_crypto.ENV_PRESENTATION_LOGIC
assert link_crypto.ENV_PRESENTATION_RENDER != link_crypto.ENV_LOGIC_DATA
assert "ENV_PRESENTATION_RENDER" in SRC
def test_report_goes_through_the_encrypted_link():
assert "link_crypto.call(" in SRC
assert "link=_link()" in SRC
def test_every_outbound_call_carries_token_and_key():
"""Ten sam niezmiennik co przy kliencie logiki — tam brak nagłówka wyszedł
dopiero na produkcji (401)."""
import ast
tree = ast.parse(SRC)
calls = [n for n in ast.walk(tree)
if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)
and n.func.attr in ("call", "call_json")]
assert calls, "nie znaleziono wywołania przez link_crypto"
for c in calls:
kwargs = {k.arg for k in c.keywords}
assert "headers" in kwargs and "link" in kwargs
def test_health_is_the_only_unencrypted_path():
"""/health musi zostać otwarty dla sond k8s — ale nic poza nim."""
from app import link_crypto
assert "/health" in link_crypto.PUBLIC_PATHS
raw = [line for line in SRC.splitlines() if "client.get(" in line or "client.post(" in line]
assert all("/health" in line for line in raw), f"surowe wywołania poza /health: {raw}"
def test_key_is_read_lazily():
"""Konfiguracja może się zmienić bez restartu procesu (podmiana sekretu)."""
assert "def _link()" in SRC and "key_from_env" in SRC
# ──────────────────────── kosmogram w wariancie do druku ─────────────────
def test_pdf_route_uses_print_theme():
"""Samodzielny konwerter SVG→PDF nie zna naszego arkusza stylów."""
assert 'chartwheel.render(chart, theme="print")' in MAIN
def test_print_theme_has_no_css_variables():
"""`var(--…)` w SVG poza stroną nie rozwiąże się — wyszłyby czarne albo
żadne kreski."""
from app import chartwheel
chart = _sample_chart()
printed = chartwheel.render(chart, theme="print")
assert printed and "var(--" not in printed
def test_screen_theme_still_uses_css_variables():
"""Na stronie koło ma się dostrajać do motywu aplikacji."""
from app import chartwheel
assert "var(--" in chartwheel.render(_sample_chart(), theme="screen")
def test_print_theme_embeds_the_glyph_font():
"""Font glifów podaje CSS klasy `.glyph` — poza stroną trzeba go wpisać
W SAM rysunek, inaczej kosmogram w PDF wyszedłby BEZ symboli."""
from app import chartwheel
printed = chartwheel.render(_sample_chart(), theme="print")
assert 'font-family=' in printed
assert "DejaVu Sans" in printed # font obecny w obrazie usługi render
def test_screen_theme_leaves_font_to_css():
from app import chartwheel
assert "font-family=" not in chartwheel.render(_sample_chart(), theme="screen")
def test_unknown_theme_falls_back_to_screen():
from app import chartwheel
assert chartwheel.render(_sample_chart(), theme="cokolwiek") == \
chartwheel.render(_sample_chart(), theme="screen")
# ─────────────────────────── składanie po stronie klienta ────────────────
def test_browser_sends_all_three_parts():
for field in ("person", "natal", "predictions"):
assert field in COMPILE_JS, f"raport wysyłany bez części: {field}"
def test_pdf_button_exists():
tpl = (APP / "templates" / "compile.html").read_text(encoding="utf-8")
assert 'id="pdfBtn"' in tpl
def test_wheel_failure_does_not_block_the_report():
"""Brak rysunku ma dać raport bez kosmogramu, a nie brak raportu — tekst
kosztował wywołanie modelu, rysunek policzymy zawsze."""
assert "wheel_svg = \"\"" in MAIN or 'wheel_svg, = ""' in MAIN
assert "Brak rysunku nie może zablokować raportu" in MAIN
# ─────────────────────────────────── pomocnicze ──────────────────────────
def _sample_chart():
signs = ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"]
glyphs = ["", "", "", "", "", "", "", "", "", "", "", ""]
asc = 128.9
asc_sign = int(asc // 30)
return {
"angles": {"Asc": {"name": "Asc", "decimal": asc, "sign": signs[asc_sign]},
"MC": {"name": "MC", "decimal": 5.0},
"Dsc": {"name": "Dsc", "decimal": (asc + 180) % 360},
"IC": {"name": "IC", "decimal": 185.0}},
"cusps": [{"house": i + 1, "sign": signs[(asc_sign + i) % 12],
"decimal": (asc_sign * 30 + i * 30) % 360} for i in range(12)],
"sign_glyphs": [{"sign": s, "glyph": g} for s, g in zip(signs, glyphs)],
"positions": [{"name": "Sun", "decimal": 40.2, "glyph": "", "direction": "D"}],
}