8557bfe230
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.
233 lines
12 KiB
Python
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)
|