Files
Lucas Orth 0341c4eaea Initiale Ablage der umsortierten Skrift-Programme
Struktur: Docker/ (skrift-backend, signature-service), Webseite/
(skrift-configurator, skrift-job-manager), Produktion/ (skrift-produktion,
AutoRecover). Aufgeräumt: signature-to-svg entfernt, Backend-PayPal-Service
entfernt (PayPal läuft client-seitig im Configurator), Dev-/Test-Artefakte
und Deployment-Dokus bereinigt, .dockerignore repariert. CLAUDE.md mit
Architektur ergänzt. Secrets (.env, config.json) via .gitignore ausgeschlossen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 12:21:08 +02:00

287 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Minimale Tests für die Kernlogik: Token-Suche, Geometrie, Fehlerfälle."""
import re
import fitz
import pytest
from core import (
PT_TO_MM,
PlacementError,
build_a4_svg,
find_token_occurrences,
parse_svg,
place_all,
signatur_geometrie,
)
from models import SignatureJob
# ── Test-Hilfen ─────────────────────────────────────────────────────────
SVG_OK = (
b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 40">'
b'<path d="M0 20 L100 20" stroke="black"/></svg>'
)
SVG_NO_VIEWBOX = (
b'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="40">'
b'<path d="M0 20 L100 20"/></svg>'
)
def make_pdf(tokens_at):
"""Erzeugt ein A4-PDF (1 Seite) mit den Token an gegebenen Punkt-Positionen."""
doc = fitz.open()
page = doc.new_page(width=595.0, height=842.0) # A4 in pt
for token, (x, y) in tokens_at:
page.insert_text((x, y), token, fontsize=11)
pdf_bytes = doc.tobytes()
doc.close()
return pdf_bytes
def _translate_from_svg(a4_svg):
"""Liest tx, ty aus dem ersten translate(...) des A4-SVG."""
m = re.search(r"translate\(([-\d.]+),([-\d.]+)\)", a4_svg)
assert m, "kein translate gefunden"
return float(m.group(1)), float(m.group(2))
def _scale_from_svg(a4_svg):
m = re.search(r"scale\(([-\d.]+)\)", a4_svg)
assert m, "kein scale gefunden"
return float(m.group(1))
# ── viewBox / SVG ───────────────────────────────────────────────────────
def test_parse_svg_liest_viewbox():
vb = parse_svg(SVG_OK)
assert (vb.minx, vb.miny, vb.vw, vb.vh) == (0.0, 0.0, 100.0, 40.0)
assert "<path" in vb.inner
def test_parse_svg_ohne_viewbox_fehler():
with pytest.raises(PlacementError):
parse_svg(SVG_NO_VIEWBOX)
# ── Geometrie ───────────────────────────────────────────────────────────
def test_geometrie_unter():
vb = parse_svg(SVG_OK)
# Token bei y0 = 72 pt = 25.4 mm
x_mm, y_sig, skala, h = signatur_geometrie(
x0_pt=72.0, y0_pt=72.0, vb=vb, position="unter", breite_mm=45.0, abstand_mm=8.0
)
assert skala == pytest.approx(45.0 / 100.0)
assert h == pytest.approx(40.0 * 0.45) # 18 mm
assert x_mm == pytest.approx(72.0 * PT_TO_MM) # 25.4 mm
# unter: y_token + abstand
assert y_sig == pytest.approx(72.0 * PT_TO_MM + 8.0) # 33.4 mm
def test_geometrie_genau():
vb = parse_svg(SVG_OK)
# "genau": Signatur sitzt exakt an der Marker-Position (kein Abstand).
x_mm, y_sig, skala, h = signatur_geometrie(
x0_pt=72.0, y0_pt=72.0, vb=vb, position="genau", breite_mm=45.0, abstand_mm=8.0
)
assert y_sig == pytest.approx(72.0 * PT_TO_MM) # == y_token, abstand ignoriert
assert x_mm == pytest.approx(72.0 * PT_TO_MM)
def test_position_default_ist_genau():
job = SignatureJob(token="§§SIG1§§", svg_id="s.svg")
assert job.position == "genau"
def test_geometrie_ueber():
vb = parse_svg(SVG_OK)
x_mm, y_sig, skala, h = signatur_geometrie(
x0_pt=72.0, y0_pt=72.0, vb=vb, position="ueber", breite_mm=45.0, abstand_mm=8.0
)
# ueber: y_token - abstand - hoehe
expected = 72.0 * PT_TO_MM - 8.0 - h
assert y_sig == pytest.approx(expected)
# über muss deutlich höher (kleineres y) liegen als unter
assert y_sig < 72.0 * PT_TO_MM
def test_geometrie_viewbox_offset_in_translate():
"""Ein viewBox-Offset muss in die Translation eingerechnet werden."""
svg = (
b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="10 5 100 40">'
b'<path d="M10 25 L110 25"/></svg>'
)
vb = parse_svg(svg)
rect = fitz.Rect(72.0, 72.0, 120.0, 84.0)
job = SignatureJob(token="§§SIG1§§", svg_id="s.svg", position="unter")
a4 = build_a4_svg(vb, rect, job)
tx, ty = _translate_from_svg(a4)
skala = _scale_from_svg(a4)
# tx = x_sig - minx*skala
assert tx == pytest.approx(72.0 * PT_TO_MM - 10.0 * skala, abs=1e-3)
def test_versatz_verschiebt_links_rechts_oben_unten():
vb = parse_svg(SVG_OK)
rect = fitz.Rect(72.0, 72.0, 120.0, 84.0)
base = SignatureJob(token="§§S§§", svg_id="s.svg", position="genau")
moved = SignatureJob(token="§§S§§", svg_id="s.svg", position="genau",
versatz_x_mm=5.0, versatz_y_mm=-3.0)
a4_base = build_a4_svg(vb, rect, base)
a4_moved = build_a4_svg(vb, rect, moved)
bx, by = _translate_from_svg(a4_base)
mx, my = _translate_from_svg(a4_moved)
assert mx == pytest.approx(bx + 5.0, abs=1e-3) # +x → nach rechts
assert my == pytest.approx(by - 3.0, abs=1e-3) # −y → nach oben
def test_immer_single_stroke_fill_none_und_stroke():
"""Signaturen werden immer als Single-Stroke gezeichnet (fill:none + stroke)."""
vb = parse_svg(SVG_OK)
rect = fitz.Rect(72.0, 72.0, 120.0, 84.0)
job = SignatureJob(token="§§S§§", svg_id="s.svg", position="genau",
breite_mm=50.0, linienstaerke_mm=0.5)
a4 = build_a4_svg(vb, rect, job)
assert 'fill="none"' in a4
assert "stroke=" in a4 and "stroke-width=" in a4
# stroke-width in viewBox-Einheiten: 0.5mm / skala (skala = 50/100 = 0.5) = 1.0
import re
sw = float(re.search(r'stroke-width="([\d.]+)"', a4).group(1))
assert sw == pytest.approx(1.0, abs=1e-3)
def test_root_transform_wird_nicht_uebernommen():
"""Ein transform auf der äußersten <svg> darf die Platzierung nicht verfälschen."""
svg = (
b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 40" '
b'transform="rotate(45)" fill="blue"><path d="M0 20 L100 20"/></svg>'
)
vb = parse_svg(svg)
assert "transform" not in vb.group_attrs # nicht mitgeführt
assert vb.group_attrs.get("fill") == "blue" # echtes Styling bleibt
rect = fitz.Rect(72.0, 72.0, 120.0, 84.0)
a4 = build_a4_svg(vb, rect, SignatureJob(token="§§S§§", svg_id="s.svg"))
# Genau ein transform (die Positionierung), nicht zwei.
assert a4.count("transform=") == 1
# ── Token-Suche ─────────────────────────────────────────────────────────
def test_find_token_einmal():
pdf = make_pdf([("§§SIG1§§", (100.0, 200.0))])
doc = fitz.open(stream=pdf, filetype="pdf")
occ = find_token_occurrences(doc, "§§SIG1§§")
doc.close()
assert len(occ) == 1
page_index, rect = occ[0]
assert page_index == 0
assert rect.x0 == pytest.approx(100.0, abs=2.0) # nahe Einfügepunkt
def test_find_token_nicht_gefunden():
pdf = make_pdf([("§§SIG1§§", (100.0, 200.0))])
doc = fitz.open(stream=pdf, filetype="pdf")
assert find_token_occurrences(doc, "§§SIG9§§") == []
doc.close()
def test_find_token_mehrfach_erlaubt():
# Derselbe Token mehrfach (z.B. Unterschrift auf mehreren Stellen) → alle Treffer.
pdf = make_pdf([("§§SIG1§§", (100.0, 200.0)), ("§§SIG1§§", (100.0, 400.0))])
doc = fitz.open(stream=pdf, filetype="pdf")
occ = find_token_occurrences(doc, "§§SIG1§§")
doc.close()
assert len(occ) == 2
# ── place_all: Gesamtfluss + Fehlerliste ────────────────────────────────
def make_multipage_pdf(per_page_tokens):
"""Erzeugt ein A4-PDF mit mehreren Seiten; pro Seite eine Liste (token, (x,y))."""
doc = fitz.open()
for tokens in per_page_tokens:
page = doc.new_page(width=595.0, height=842.0)
for token, (x, y) in tokens:
page.insert_text((x, y), token, fontsize=11)
data = doc.tobytes()
doc.close()
return data
def test_place_all_erfolg():
pdf = make_pdf([("§§SIG1§§", (100.0, 200.0))])
jobs = [SignatureJob(token="§§SIG1§§", svg_id="unterschrift.svg", position="unter")]
pages, errors = place_all(pdf, {"unterschrift.svg": SVG_OK}, jobs)
assert errors == []
assert len(pages) == 1
p = pages[0]
assert p.filename == "seite_1.svg"
assert p.tokens == ["§§SIG1§§"]
assert p.a4_svg.startswith("<svg")
assert 'viewBox="0 0 210 297"' in p.a4_svg
def test_place_all_token_auf_mehreren_seiten():
"""Derselbe Token auf mehreren Seiten → pro Seite eine A4-SVG."""
pdf = make_multipage_pdf([
[("[[sign]]", (100.0, 300.0))],
[("[[sign]]", (100.0, 300.0))],
[("[[sign]]", (100.0, 300.0))],
])
jobs = [SignatureJob(token="[[sign]]", svg_id="u.svg", position="genau")]
pages, errors = place_all(pdf, {"u.svg": SVG_OK}, jobs)
assert errors == []
assert [p.filename for p in pages] == ["seite_1.svg", "seite_2.svg", "seite_3.svg"]
def test_place_all_zwei_tokens_pro_seite_kombiniert():
"""Zwei verschiedene Token auf jeder Seite → eine A4-SVG pro Seite mit beiden."""
pdf = make_multipage_pdf([
[("[[fk]]", (100.0, 600.0)), ("[[tv]]", (300.0, 600.0))],
[("[[fk]]", (100.0, 600.0)), ("[[tv]]", (300.0, 600.0))],
])
jobs = [
SignatureJob(token="[[fk]]", svg_id="a.svg", position="genau"),
SignatureJob(token="[[tv]]", svg_id="b.svg", position="genau"),
]
pages, errors = place_all(pdf, {"a.svg": SVG_OK, "b.svg": SVG_OK}, jobs)
assert errors == []
assert len(pages) == 2
# Jede Seiten-SVG enthält beide Signaturen (zwei positionierte Gruppen).
for p in pages:
assert sorted(p.tokens) == ["[[fk]]", "[[tv]]"]
assert p.a4_svg.count("translate(") == 2
def test_place_all_svg_fehlt():
pdf = make_pdf([("§§SIG1§§", (100.0, 200.0))])
jobs = [SignatureJob(token="§§SIG1§§", svg_id="fehlt.svg", position="unter")]
placements, errors = place_all(pdf, {}, jobs)
assert placements == []
assert any("nicht hochgeladen" in e for e in errors)
def test_place_all_token_fehlt():
pdf = make_pdf([("§§SIG1§§", (100.0, 200.0))])
jobs = [SignatureJob(token="§§SIG2§§", svg_id="s.svg", position="unter")]
placements, errors = place_all(pdf, {"s.svg": SVG_OK}, jobs)
assert placements == []
assert any("nicht im PDF" in e for e in errors)
def test_place_all_sammelt_mehrere_fehler():
"""Mehrere fehlerhafte Jobs → alle Fehler werden gemeldet, nicht nur der erste."""
pdf = make_pdf([("§§SIG1§§", (100.0, 200.0))])
jobs = [
SignatureJob(token="§§SIGX§§", svg_id="s.svg", position="unter"), # Token fehlt
SignatureJob(token="§§SIG1§§", svg_id="fehlt.svg", position="unter"), # SVG fehlt
]
placements, errors = place_all(pdf, {"s.svg": SVG_OK}, jobs)
assert placements == []
assert len(errors) == 2
def test_place_all_svg_ohne_viewbox():
pdf = make_pdf([("§§SIG1§§", (100.0, 200.0))])
jobs = [SignatureJob(token="§§SIG1§§", svg_id="s.svg", position="unter")]
placements, errors = place_all(pdf, {"s.svg": SVG_NO_VIEWBOX}, jobs)
assert placements == []
assert any("viewBox" in e for e in errors)