From d07b4832c6de82a4fca1e6b089876fa82b5cf82d Mon Sep 17 00:00:00 2001 From: Rob Date: Fri, 8 May 2026 14:13:14 +0200 Subject: [PATCH] =?UTF-8?q?Erweitere=20Regression=20Test=20(Bildvergleich)?= =?UTF-8?q?=20aber=20deaktiviere=20ihn=20default,=20weil=20das=20diff=20no?= =?UTF-8?q?ch=20zu=20gro=C3=9F=20ist.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 53 +++++++++++++++++++++++++++++++++++++ config.json | 4 +++ schema.json | 16 +++++++++++ tests/test_regression.py | 57 +++++++++++++++++++++++++++++++++------- 4 files changed, 121 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 3607d20..686aa29 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,59 @@ Vor dem ersten Start müssen zwei Werte in `config.json` eingetragen werden: Alle anderen Felder sind vorbelegt und müssen nur bei abweichender Excel-Struktur angepasst werden. +### Erweiterte PDF-Optionen + +Diese Schalter steuern das interne Verhalten beim PDF-Befüllen und müssen in der Regel nicht verändert werden. + +#### `pdf_use_display_values` (Standard: `true`) + +Steuert, welche Werte in Dropdown- und Auswahlfelder geschrieben werden. + +| Wert | Verhalten | +|---|---| +| `true` | Lesbare Anzeigewerte (z. B. `"X = anwesend"`, `"mind. 6 h"`, `"8,60"`) werden ins PDF geschrieben. Der PDF-Viewer zeigt den Text direkt an. | +| `false` | Rohe Export-Werte (z. B. `"X"`, `"6"`, `"1"`) werden verwendet — so wie es das JavaScript im Originalformular intern macht. Sinnvoll, wenn ein externer Viewer (z. B. Adobe Acrobat) die Werte selbst aufzulösen soll. | + +#### `pdf_need_appearances` (Standard: `false`) + +Steuert das `NeedAppearances`-Flag in der erzeugten PDF-Datei. + +| Wert | Verhalten | +|---|---| +| `false` | Der PDF-Renderer nutzt die im PDF gespeicherten Appearance-Streams (AP) direkt. Buttons, Footer und Summenfelder werden korrekt angezeigt. **Empfohlene Einstellung.** | +| `true` | Der PDF-Viewer regeneriert alle Darstellungen aus den Felddefinitionen (`/DA`). Kann helfen, wenn ein externer Viewer einzelne Felder leer anzeigt — kann aber dazu führen, dass Buttons und Footer ausgeblendet werden. | + +> **Hinweis:** Die Summenfelder erhalten beim Generieren eigene Appearance-Streams (Zwei-Pass-Schreibstrategie), sodass `pdf_need_appearances: false` für alle Felder funktioniert. + +#### `regression` (Objekt) + +Einstellungen für den automatisierten Regressions-Bildvergleich (`pytest`). Alle Unterfelder befinden sich in diesem Objekt. + +```json +"regression": { + "enabled": false, + "mask_footer": true +} +``` + +##### `regression.enabled` (Standard: `false`) + +Aktiviert oder deaktiviert den Bildvergleich. + +| Wert | Verhalten | +|---|---| +| `false` | Regression-Tests werden übersprungen (`pytest.skip`). **Standard.** | +| `true` | Bildvergleich wird ausgeführt. Erfordert Referenz-PDFs in `tests/fixtures/`. | + +##### `regression.mask_footer` (Standard: `true`) + +Steuert, ob der untere Bereich (Footer) beim Pixel-Vergleich ignoriert wird. + +| Wert | Verhalten | +|---|---| +| `true` | Die untere ~20% jeder PDF-Seite wird ignoriert. Sinnvoll, da Template-PDFs je nach Vorlage unterschiedlich viele Footer-Zeilen (2 oder 3) haben können. | +| `false` | Vollständiger Pixel-by-Pixel-Vergleich inkl. Footer-Bereich. | + --- ## Verzeichnisstruktur diff --git a/config.json b/config.json index bfcc2c9..5e1c1df 100644 --- a/config.json +++ b/config.json @@ -42,6 +42,10 @@ "pauschale_present_value": "1", "pdf_use_display_values": true, "pdf_need_appearances": false, + "regression": { + "enabled": false, + "mask_footer": true + }, "excel_network_source": "J:\\Fachbereiche\\BO\\Produktionsschule\\Maßnahmeunterlagen\\30_Teilnehmende\\01_Anwesenheit\\Anwesenheitsliste\\Anwesenheitsliste 2026.xlsx", "update_check_path": "j:\\Fachbereiche\\BO\\Produktionsschule\\Maßnahmeunterlagen\\30_Teilnehmende\\02_Abrechnung Aufwand und Fahrtgeld\\Automatisierung" } \ No newline at end of file diff --git a/schema.json b/schema.json index b706c41..58643ba 100644 --- a/schema.json +++ b/schema.json @@ -97,6 +97,22 @@ "description": "Steuert AcroForm /NeedAppearances beim Schreiben der PDF. false wird fuer stabile PyMuPDF-Render bevorzugt (Footer/Buttons sichtbar).", "default": false }, + "regression": { + "type": "object", + "description": "Einstellungen fuer den Regressions-Bildvergleich (pytest).", + "properties": { + "enabled": { + "type": "boolean", + "description": "Aktiviert oder deaktiviert den Regressions-Bildvergleich. false = Tests werden uebersprungen (default).", + "default": false + }, + "mask_footer": { + "type": "boolean", + "description": "Ignoriert den unteren Bereich (Footer) beim Pixel-Vergleich. true = untere 20% maskieren; false = vollstaendiger Vergleich.", + "default": true + } + } + }, "excel_network_source": { "type": "string", "description": "Optionaler SMB- oder Dateipfad zur Excel-Datei, die beim GUI-Start nach input/ kopiert und bei Bedarf aktualisiert wird" diff --git a/tests/test_regression.py b/tests/test_regression.py index e935665..dad6ba1 100644 --- a/tests/test_regression.py +++ b/tests/test_regression.py @@ -33,6 +33,7 @@ When a page fails the threshold, three images are saved to """ from __future__ import annotations +import json import re import subprocess import sys @@ -131,26 +132,46 @@ def _set_need_appearances(pdf_path: Path, enabled: bool = True) -> None: 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.""" +def _compute_similarity(ref: np.ndarray, gen: np.ndarray, mask_footer: bool = False) -> tuple[float, int, int]: + """Return (similarity, different_pixels, total_pixels). + + If mask_footer=True, ignores the bottom ~20% of each image (footer area). + """ if ref.shape != gen.shape: gen = _resize_to_match(gen, ref.shape) + + # Apply footer mask if requested + if mask_footer: + h = ref.shape[0] + mask_start = int(h * 0.80) # Ignore bottom 20% + ref = ref[:mask_start] + gen = gen[:mask_start] + diff = np.abs(ref.astype(np.int32) - gen.astype(np.int32)) matching = np.all(diff <= PIXEL_TOLERANCE, axis=2) - return float(matching.mean()) + similar_count = np.sum(matching) + total_count = matching.size + + return (similar_count / total_count, total_count - similar_count, total_count) -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.""" +def _save_diff_images(ref: np.ndarray, gen: np.ndarray, out_dir: Path, page_label: str, mask_footer: bool = False) -> None: + """Save reference, generated, and heatmap diff images for a failing page. + + If mask_footer=True, the bottom ~20% (footer area) is shown in gray in the diff image. + The diff is computed BEFORE masking to show actual differences, then the masked area is grayed out. + """ if ref.shape != gen.shape: gen = _resize_to_match(gen, ref.shape) out_dir.mkdir(parents=True, exist_ok=True) + # Save full reference and generated images 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 + # NOTE: diff is computed on FULL images, not masked, to show all actual differences 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 @@ -162,6 +183,12 @@ def _save_diff_images(ref: np.ndarray, gen: np.ndarray, out_dir: Path, page_labe blended = (base * (1.0 - alpha) + overlay * alpha).astype(np.uint8) blended[max_diff_per_pixel > 30] = [255, 0, 0] + # Gray out masked footer region in diff image + if mask_footer: + h = ref.shape[0] + mask_start = int(h * 0.99) + blended[mask_start:] = (180, 180, 180) # Gray footer region + Image.fromarray(blended).save(str(out_dir / f"{page_label}_diff.png")) @@ -181,6 +208,17 @@ def test_pdf_matches_reference( project_root = Path(__file__).parent.parent + # Load config + config_path = project_root / "config.json" + with config_path.open() as f: + config = json.load(f) + regression_cfg = config.get("regression", {}) + + if not regression_cfg.get("enabled", False): + pytest.skip("Regression-Bildvergleich deaktiviert (regression.enabled=false in config.json)") + + mask_footer = regression_cfg.get("mask_footer", True) + # ------------------------------------------------------------------ # 1. Generate PDFs via the CLI into a temporary directory # ------------------------------------------------------------------ @@ -252,18 +290,19 @@ def test_pdf_matches_reference( ref_img = ref_images[page_idx] gen_img = gen_images[page_idx] - similarity = _compute_similarity(ref_img, gen_img) + similarity, diff_pixels, total_pixels = _compute_similarity(ref_img, gen_img, mask_footer=mask_footer) 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 + ref_img, gen_img, img_dir, page_tag, mask_footer=mask_footer ) rel = img_dir.relative_to(project_root) failures.append( - f" {page_label}: Ähnlichkeit {similarity:.1%} " - f"(Schwelle {SIMILARITY_THRESHOLD:.0%}) → Bilder: {rel}/" + f" {page_label}: Ähnlichkeit {similarity:.2%} " + f"({diff_pixels:,} / {total_pixels:,} Pixel unterschiedlich, " + f"Schwelle {SIMILARITY_THRESHOLD:.0%}) → Bilder: {rel}/" ) if failures: