#!/usr/bin/env python3 """Regressionstest für shell_beleg_parser.py gegen elf echte Shell-eReceipts. Die PDFs in belege/ sind reale, vom Nutzer bereitgestellte Tankbelege aus fünf verschiedenen Stationen (Ingolstadt/Zrenner an zwei Adressen, Rain am Lech/Bauch, Ansbach/Sengül, Königsbronn/Mogler) und decken damit die tatsächlich beobachtete Formatvielfalt ab, nicht nur eine angenommene Struktur: - Markenzeile: "SHELL STATION" / "Shell Station" / "Shell-Station" - Rabattbezeichnung: "V-Power Smart Deal" / "ClubSmartRabatt" - POS-Zeile: mit und ohne "68002-"-Präfix vor der Stations-ID - unterschiedliche Fußzeilen-Länge/-Inhalte Aufruf: python3 tests/belegparser/test_shell_beleg_parser.py (braucht pypdf; in Home Assistant bringt die Integration es über manifest.json mit, hier reicht ein "pip install pypdf") """ import os import sys import unittest _HIER = os.path.dirname(os.path.abspath(__file__)) _BELEGE = os.path.join(_HIER, "belege") sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(_HIER)), "custom_components", "audi_dashboard")) import shell_beleg_parser as p # noqa: E402 def _parsen_datei(dateiname): pfad = os.path.join(_BELEGE, dateiname) return p._parsen(p._text_aus_pdf(pfad)) class AlleBelegeParsenOhneFehler(unittest.TestCase): """Jeder der zehn echten Belege muss ohne Exception durchlaufen und strukturell plausible Werte liefern - das ist der eigentliche Zweck dieser Suite: künftige Regex-Änderungen dürfen keinen der bekannten realen Belegtypen mehr brechen.""" @classmethod def setUpClass(cls): cls.dateien = sorted(f for f in os.listdir(_BELEGE) if f.endswith(".pdf")) cls.ergebnisse = {f: _parsen_datei(f) for f in cls.dateien} def test_elf_belege_gefunden(self): self.assertEqual(len(self.dateien), 11, self.dateien) def test_pflichtfelder_gesetzt(self): pflichtfelder = [ "receipt_key", "receipt_no", "ts", "station_id", "station_brand", "station_name", "station_address", "article_no", "product_name", "fuel_type", "liters", "list_price_per_l", "fuel_total_eur", "receipt_total_eur", "net_eur", "vat_eur", ] for datei, d in self.ergebnisse.items(): for feld in pflichtfelder: with self.subTest(datei=datei, feld=feld): self.assertIsNotNone(d[feld], f"{feld} fehlt in {datei}") def test_betraege_plausibel(self): for datei, d in self.ergebnisse.items(): with self.subTest(datei=datei): self.assertGreater(d["liters"], 0) self.assertGreater(d["list_price_per_l"], 0) self.assertGreater(d["fuel_total_eur"], 0) # Netto + MwSt muss dem Bruttobetrag entsprechen (Rundung: 1 Cent) self.assertAlmostEqual(d["net_eur"] + d["vat_eur"], d["receipt_total_eur"], delta=0.01) # Gesamtbetrag der Position == Bruttobetrag der Steuerzeile self.assertAlmostEqual(d["fuel_total_eur"], d["receipt_total_eur"], delta=0.01) if d["discount"] is not None: self.assertGreater(d["discount"], 0) # Rabatt darf den Bruttobetrag nicht übersteigen self.assertLess(d["discount"], d["fuel_total_eur"]) def test_marke_als_eigenes_feld(self): # Seit dem 04.09.2026 steht die Kopfmarke ZUSAETZLICH als eigenes Feld # da, nicht nur in station_name eingefaltet. Genau daran fehlte sie # jedem Beleg, den ein aelterer Parser eingelesen hatte: dort blieb der # Betreibername stehen, und nachtraeglich war die Marke nirgends mehr # zu holen (Befund des Eigentuemers, 04.09.2026). for datei, d in self.ergebnisse.items(): with self.subTest(datei=datei): self.assertEqual(d["station_brand"], "Shell") def test_marke_ohne_vollstaendiges_einlesen(self): # marke_aus_pdf() liest nur den Belegkopf. Das ist der Weg, ueber den # belege.marken_nachtragen() die Marke bei Altbelegen nachtraegt - er # darf nicht vom vollstaendigen Parsen abhaengen. for datei in self.dateien: with self.subTest(datei=datei): self.assertEqual(p.marke_aus_pdf(os.path.join(_BELEGE, datei)), "Shell") def test_marke_aus_pdf_ohne_datei(self): # Eine fehlende Belegdatei ist kein Fehlerfall - marken_nachtragen() # laeuft ueber Vorgaenge, deren PDF laengst geloescht sein kann. self.assertIsNone(p.marke_aus_pdf(os.path.join(_BELEGE, "gibtesnicht.pdf"))) def test_receipt_key_eindeutig(self): schluessel = [d["receipt_key"] for d in self.ergebnisse.values()] self.assertEqual(len(schluessel), len(set(schluessel)), schluessel) def test_bekannte_stationen(self): # Fünf verschiedene Standorte in den elf Belegen - stellt sicher, dass # die Stationserkennung nicht zufällig nur für einen Beleg passt. # Anzeigeformat seit 2026-08-16: "Marke, Straße, Ort" statt des reinen # Betreibernamens ("A. Zrenner GmbH" o. ä.) - der Betreiber sagt beim # Durchblättern der Tankvorgänge weniger als Marke und Ort. namen = {d["station_name"] for d in self.ergebnisse.values()} erwartet = { "Shell, Pascalstr. 8, Ingolstadt", "Shell, Münchener Str.15a, Ingolstadt", "Shell, Nürnberger Str.74, Ansbach", "Shell, Heidenheimerstr.6, Königsbronn", "Shell, Richard-Wagner-Str. 9, Ingolstadt", } self.assertEqual(namen, erwartet) def test_rabatt_label_unabhaengig(self): # e-receipt7.pdf nutzt "ClubSmartRabatt" statt "V-Power Smart Deal" - # die Rabatterkennung darf nicht am Label hängen, nur am Betragsmuster. d = self.ergebnisse["e-receipt7.pdf"] self.assertAlmostEqual(d["discount"], 6.95, delta=0.001) self.assertAlmostEqual(d["discount_per_l"], 0.18, delta=0.001) def test_stichproben_werte(self): # Punktuelle Kontrolle einzelner, von Hand aus dem Beleg abgelesener # Werte - fängt stille Verschiebungen (z.B. falsche Regex-Gruppe) ab, # die die reinen Plausibilitätschecks oben nicht erkennen würden. stichproben = { "e-receipt.pdf": dict(liters=42.15, fuel_total_eur=78.78, discount=11.80, station_id="0000007770"), "e-receipt3.pdf": dict(liters=21.73, fuel_total_eur=44.53, discount=6.08, station_id="0000000456"), "e-receipt5.pdf": dict(liters=47.76, fuel_total_eur=79.23, discount=12.90, station_id="0000002227"), "e-receipt9.pdf": dict(liters=51.59, fuel_total_eur=85.59, discount=13.41, station_id="0000002103"), # Der Beleg, der in der App nur mit Datum und Uhrzeit ankam # (Eigentuemer, 04.09.2026). Der Parser hat ihn immer vollstaendig # gelesen - der Fehler sass in Tanken.tsx, das andere Feldnamen # abfragte, als belege.py veroeffentlicht. "geteilt-2026-09-04.pdf": dict(liters=43.87, fuel_total_eur=95.60, discount=11.84, station_id="0000000459"), } for datei, erwartet in stichproben.items(): d = self.ergebnisse[datei] with self.subTest(datei=datei): self.assertAlmostEqual(d["liters"], erwartet["liters"], delta=0.001) self.assertAlmostEqual(d["fuel_total_eur"], erwartet["fuel_total_eur"], delta=0.001) self.assertAlmostEqual(d["discount"], erwartet["discount"], delta=0.001) self.assertEqual(d["station_id"], erwartet["station_id"]) class BelegOhneRabatt(unittest.TestCase): """SmartDeal/ClubSmart ist optional - nicht jeder Tankvorgang hat einen Rabatt. Simuliert durch Entfernen der Rabattzeilen aus einem echten Beleg, da keiner der elf realen Belege rabattfrei war.""" def test_ohne_rabatt_bleibt_alles_andere_korrekt(self): text = p._text_aus_pdf(os.path.join(_BELEGE, "e-receipt.pdf")) ohne_rabatt = "\n".join( z for z in text.split("\n") if "Smart Deal" not in z and "EUR/Liter" not in z ) d = p._parsen(ohne_rabatt) self.assertIsNone(d["discount"]) self.assertIsNone(d["discount_per_l"]) self.assertAlmostEqual(d["fuel_total_eur"], 78.78, delta=0.001) self.assertAlmostEqual(d["liters"], 42.15, delta=0.001) class MarkenerkennungGrenzen(unittest.TestCase): """Was die Markenerkennung leistet - und was ausdrücklich nicht. Belege sind nicht genormt. Diese Suite hält fest, wo die Erkennung greift, wo sie aufgibt, und dass ein Aufgeben den Beleg nicht beschädigt. """ @classmethod def setUpClass(cls): cls.zeilen = p._text_aus_pdf(os.path.join(_BELEGE, "e-receipt.pdf")).splitlines() def test_marke_steht_ganz_oben(self): # Gemessen am 04.09.2026: in allen elf Belegen in Zeile 0. Das Fenster # von acht Zeilen ist der Puffer, nicht die Erwartung - der # österreichische Beleg (nur in der Instanz) hat sie in Zeile 1. for datei in sorted(f for f in os.listdir(_BELEGE) if f.endswith(".pdf")): with self.subTest(datei=datei): zeilen = p._text_aus_pdf(os.path.join(_BELEGE, datei)).splitlines() self.assertEqual(p._marke(zeilen[:1]), "Shell") def test_ausserhalb_des_fensters_wird_nicht_gefunden(self): # Ehrlich dokumentiert statt stillschweigend angenommen: steht die # Marke erst ab Zeile 8, findet sie niemand. verschoben = [""] * 8 + self.zeilen self.assertIsNone(p._marke(verschoben)) def test_unbekannte_marke_beschaedigt_den_beleg_nicht(self): # Freie Tankstellen und Marken, die nicht in _MARKEN stehen: der Beleg # wird vollständig eingelesen, nur eben ohne Marke. Ein falscher Name # wäre schlimmer als keiner. for ersatz in ("OIL! Tankstelle", "Hoyer Tankstelle", "Freie Tankstelle Mueller"): with self.subTest(kopf=ersatz): d = p._parsen("\n".join([ersatz] + self.zeilen[1:])) self.assertIsNone(d["station_brand"]) # Ohne Marke tritt der Betreibername an ihre Stelle. self.assertEqual(d["station_name"], "A. Zrenner GmbH, Pascalstr. 8, Ingolstadt") self.assertAlmostEqual(d["liters"], 42.15, delta=0.001) self.assertAlmostEqual(d["fuel_total_eur"], 78.78, delta=0.001) def test_summenzeile_im_kopf_ist_keine_marke(self): # "Total" ist beides - Kopfmarke und Summenwort. Ein Beleg mit knappem # Kopf, der den Betrag schon oben druckt, lieferte vorher fälschlich # die Marke "Total" (nachgestellt am 04.09.2026). for summe in ("TOTAL: 78,78 EUR", "GESAMT 78,78 EUR", "Summe 78,78 EUR"): with self.subTest(zeile=summe): self.assertEqual(p._marke([summe] + self.zeilen), "Shell") def test_echte_marke_total_bleibt_erkennbar(self): # Die Gegenprobe zum Test darüber: verworfen wird nur, wo Summenwort # UND Geldangabe zusammenkommen - eine Kopfzeile ohne Betrag nicht. self.assertEqual(p._marke(["TOTAL TANKSTELLE", "Musterweg 1", "80331 München"]), "Total") # Und eine Postleitzahl in der Kopfzeile faellt nicht mit heraus. self.assertEqual( p._marke(["AUTO B. FRISCHMANN GMBH", "SHELL TANKSTELLE, 6450 SÖLDEN"]), "Shell" ) def test_wortgrenze_schuetzt_vor_teiltreffern(self): # "Star" darf nicht in "Starnberg" treffen, "Eni" nicht in "Enisstr.". self.assertIsNone(p._marke(["Tankstelle Mueller", "Seestr. 3", "82319 Starnberg"])) self.assertIsNone(p._marke(["Freie Tankstelle", "Enisweg 4", "12345 Musterort"])) if __name__ == "__main__": unittest.main(verbosity=2)