Files
sufw-anweisenheiten/tests/test_regression.py
T
Rob 34e9aa49d6 feat(cli): add shared PDF-to-image rendering and document usage
add shared render_pdf_pages helper for consistent rasterization
reuse the same renderer in regression test and CLI flow
add new render-image CLI command to export PDF pages as PNG
support single-page render (--page) and full export (--all-pages)
add configurable output path and DPI options
document the new rendering command and options in README
2026-04-17 13:48:27 +02:00

273 lines
9.8 KiB
Python

"""Regression tests: pixel-diff comparison of script-generated vs. manually created reference PDFs.
Fixture layout
--------------
tests/fixtures/
YYYY_MMM/ ← directory name encodes year and Excel sheet name
reference/
(1) Name.pdf ← manually created ground-truth PDFs
diff/ ← diff images written here on failure
A fixture is discovered automatically when:
- The directory name matches YYYY_MMM (e.g. 2026_Feb)
- At least one *.xlsx / *.xlsm file exists in the directory or in input/
- At least one *.pdf file exists in reference/
Rendering
---------
Both reference and generated PDFs are rendered to images using pymupdf.
The images are then compared pixel-by-pixel.
Similarity threshold
--------------------
SIMILARITY_THRESHOLD (100%) — fraction of pixels whose R, G, B channels
each differ by at most PIXEL_TOLERANCE (default 5) between reference and generated PDF.
Diff images
-----------
When a page fails the threshold, three images are saved to
tests/fixtures/YYYY_MMM/diff/<pdf-stem>/seite_NN/
- seite_NN_reference.png
- seite_NN_generated.png
- seite_NN_diff.png (heatmap: red = difference)
"""
from __future__ import annotations
import re
import subprocess
import sys
from pathlib import Path
import numpy as np
import pytest
from pypdf import PdfReader, PdfWriter
from pypdf.generic import BooleanObject, NameObject
from PIL import Image
from src.pdf_render import render_pdf_pages
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
FIXTURES_DIR = Path(__file__).parent / "fixtures"
SIMILARITY_THRESHOLD = 1.00 # minimum fraction of matching pixels per page
PIXEL_TOLERANCE = 5 # max per-channel delta still counted as "same"
RENDER_DPI = 150 # rendering resolution for PDF → image
# ---------------------------------------------------------------------------
# Fixture discovery (runs at collection time)
# ---------------------------------------------------------------------------
def _discover_fixture_params() -> list:
"""Return pytest.param entries for every ready fixture directory.
Excel file lookup order:
1. Any *.xlsx / *.xlsm directly inside the fixture directory.
2. Any *.xlsx / *.xlsm inside the project-level ``input/`` directory
whose filename contains the fixture year (e.g. "2026").
"""
if not FIXTURES_DIR.exists():
return []
project_root = FIXTURES_DIR.parent.parent
input_dir = project_root / "input"
params = []
for d in sorted(FIXTURES_DIR.iterdir()):
if not d.is_dir():
continue
parts = d.name.split("_", 1)
if len(parts) != 2 or not parts[0].isdigit() or len(parts[0]) != 4:
continue
year_str, month = parts
# 1. Excel file in fixture directory
xlsx_files = list(d.glob("*.xlsx")) + list(d.glob("*.xlsm"))
# 2. Fallback: input/ directory
if not xlsx_files and input_dir.exists():
xlsx_files = [
f for f in (list(input_dir.glob("*.xlsx")) + list(input_dir.glob("*.xlsm")))
if year_str in f.stem
]
if not xlsx_files:
continue
ref_dir = d / "reference"
if not ref_dir.exists() or not list(ref_dir.glob("*.pdf")):
continue
params.append(
pytest.param(d, xlsx_files[0], month, ref_dir, id=d.name)
)
return params
# ---------------------------------------------------------------------------
# Comparison helpers
# ---------------------------------------------------------------------------
def _resize_to_match(img: np.ndarray, target_shape: tuple) -> np.ndarray:
"""Resize *img* so its (h, w) matches *target_shape*."""
h, w = target_shape[:2]
pil = Image.fromarray(img).resize((w, h), Image.LANCZOS)
return np.array(pil)
def _set_need_appearances(pdf_path: Path, enabled: bool = True) -> None:
"""Set /AcroForm /NeedAppearances on *pdf_path* in-place."""
reader = PdfReader(str(pdf_path))
writer = PdfWriter()
writer.clone_document_from_reader(reader)
if "/AcroForm" in writer._root_object:
writer._root_object["/AcroForm"][NameObject("/NeedAppearances")] = BooleanObject(enabled)
with pdf_path.open("wb") as fh:
writer.write(fh)
def _compute_similarity(ref: np.ndarray, gen: np.ndarray) -> float:
"""Return the fraction of pixels where all RGB channels differ by at most PIXEL_TOLERANCE."""
if ref.shape != gen.shape:
gen = _resize_to_match(gen, ref.shape)
diff = np.abs(ref.astype(np.int32) - gen.astype(np.int32))
matching = np.all(diff <= PIXEL_TOLERANCE, axis=2)
return float(matching.mean())
def _save_diff_images(ref: np.ndarray, gen: np.ndarray, out_dir: Path, page_label: str) -> None:
"""Save reference, generated, and heatmap diff images for a failing page."""
if ref.shape != gen.shape:
gen = _resize_to_match(gen, ref.shape)
out_dir.mkdir(parents=True, exist_ok=True)
Image.fromarray(ref).save(str(out_dir / f"{page_label}_reference.png"))
Image.fromarray(gen).save(str(out_dir / f"{page_label}_generated.png"))
# Heatmap: identical areas dimmed, differences highlighted in red
diff = np.abs(ref.astype(np.int32) - gen.astype(np.int32))
max_diff_per_pixel = diff.max(axis=2)
severity = max_diff_per_pixel / 255.0
base = (ref * 0.5).astype(np.uint8)
overlay = np.zeros_like(base)
overlay[:, :, 0] = (severity * 255).astype(np.uint8)
alpha = severity[:, :, np.newaxis]
blended = (base * (1.0 - alpha) + overlay * alpha).astype(np.uint8)
blended[max_diff_per_pixel > 30] = [255, 0, 0]
Image.fromarray(blended).save(str(out_dir / f"{page_label}_diff.png"))
# ---------------------------------------------------------------------------
# Main regression test
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("fixture_dir,excel_file,month,ref_dir", _discover_fixture_params())
def test_pdf_matches_reference(
fixture_dir: Path,
excel_file: Path,
month: str,
ref_dir: Path,
tmp_path: Path,
) -> None:
"""Generate PDFs for one fixture month and compare pixel-by-pixel against reference PDFs."""
project_root = Path(__file__).parent.parent
# ------------------------------------------------------------------
# 1. Generate PDFs via the CLI into a temporary directory
# ------------------------------------------------------------------
result = subprocess.run(
[
sys.executable, "generate.py", "generate",
"--file", str(excel_file),
"--month", month,
"--out", str(tmp_path),
],
input="j\n",
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
cwd=str(project_root),
)
if result.returncode != 0:
pytest.fail(
f"generate.py schlug fehl für Fixture '{fixture_dir.name}':\n"
f"STDOUT:\n{result.stdout}\n"
f"STDERR:\n{result.stderr}"
)
# ------------------------------------------------------------------
# 2. Compare each reference PDF against the generated counterpart
# ------------------------------------------------------------------
diff_dir = fixture_dir / "diff"
reference_pdfs = sorted(ref_dir.glob("*.pdf"))
failures: list[str] = []
for ref_pdf in reference_pdfs:
# Skip PDFs with only a sequence number and no name
if re.fullmatch(r'\(\d+\)', ref_pdf.stem.strip()):
continue
# Strip manual markers (e.g. " #") from stem to find the generated file
clean_stem = re.sub(r'\s*#\s*$', '', ref_pdf.stem).rstrip()
generated_pdf = tmp_path / (clean_stem + ref_pdf.suffix)
if not generated_pdf.exists():
generated_pdf = tmp_path / ref_pdf.name
if not generated_pdf.exists():
failures.append(f" FEHLT: '{ref_pdf.name}' wurde nicht generiert.")
continue
# Regression-specific render behavior: keep NeedAppearances disabled.
# With pymupdf, NeedAppearances=True can hide widget AP content
# (footer and bottom-right buttons) during rasterization.
_set_need_appearances(generated_pdf, enabled=False)
# Render both PDFs to images
ref_images = render_pdf_pages(ref_pdf, RENDER_DPI)
gen_images = render_pdf_pages(generated_pdf, RENDER_DPI)
page_count = max(len(ref_images), len(gen_images))
for page_idx in range(page_count):
page_label = f"'{ref_pdf.name}' Seite {page_idx + 1}"
if page_idx >= len(ref_images):
failures.append(f" {page_label}: Seite nur in generierter PDF vorhanden.")
continue
if page_idx >= len(gen_images):
failures.append(f" {page_label}: Seite fehlt in generierter PDF.")
continue
ref_img = ref_images[page_idx]
gen_img = gen_images[page_idx]
similarity = _compute_similarity(ref_img, gen_img)
if similarity < SIMILARITY_THRESHOLD:
page_tag = f"seite_{page_idx + 1:02d}"
img_dir = diff_dir / clean_stem / page_tag
_save_diff_images(
ref_img, gen_img, img_dir, page_tag
)
rel = img_dir.relative_to(project_root)
failures.append(
f" {page_label}: Ähnlichkeit {similarity:.1%} "
f"(Schwelle {SIMILARITY_THRESHOLD:.0%}) → Bilder: {rel}/"
)
if failures:
failure_list = "\n".join(failures)
pytest.fail(
f"\nPixel-Diff-Fehler in Fixture '{fixture_dir.name}':\n{failure_list}"
)