Files
audi-app/tests/belegparser/test_shell_beleg_parser.py
tobias 8557bfe230 Markenerkennung: Grenzen gemessen, Summenzeile im Kopf ist keine Marke
Auf die Frage, ob Belege einheitlich gestaltet sind: nein. Die Erkennung
haengt an drei Annahmen - Marke in den ersten acht Zeilen, Marke in der
festen Liste, Marke ueberhaupt vorhanden. Alle drei sind jetzt gemessen und
in Tests festgehalten.

Dabei ein echter Defekt gefunden: "Total" ist Kopfmarke UND Summenwort. Ein
Beleg mit Betrag in den ersten acht Zeilen lieferte faelschlich die Marke
"Total" (an einem echten Beleg reproduziert). _betragszeile() filtert
Summenzeilen jetzt aus dem Kopf - verlangt werden beide Kennzeichen zugleich,
Summenwort und Geldangabe, damit "SHELL TANKSTELLE, 6450 SOELDEN" nicht
mitfaellt. Gegenprobe: ein echter "TOTAL TANKSTELLE"-Kopf ergibt weiter Total.

Der Ausfall beschaedigt nichts: mit unbekannter Marke im Kopf bleiben Liter,
Betrag, Rabatt und Anschrift unveraendert richtig, nur die Marke fehlt.

Parser-Suite 17 Tests (von 11), alle zwoelf abgelegten Belege liefern weiter
Shell, audi_ha_test auf 2026.9.4.22 ohne Traceback.
2026-09-04 17:09:12 +02:00

233 lines
12 KiB
Python

#!/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)