From aae28fa7f272b71f734082c083ed08f5fc885b1b Mon Sep 17 00:00:00 2001 From: Handy-Insel Date: Mon, 2 Nov 2026 10:54:54 +0100 Subject: [PATCH] =?UTF-8?q?Absturz=20beim=20Schlie=C3=9Fen=20mit=20offenem?= =?UTF-8?q?=20Dialog=20behoben;=20GUI-Tests=20und=20Selbsttest=20der=20.ex?= =?UTF-8?q?e=20im=20Build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fehler: Programm schließen, während der Einrichtungs-Assistent offen war → Zugriff auf geschlossene Datenbank, danach Absturz des Fehler-Handlers. - beenden(): schließt zuerst offene Dialoge/Rückfragen/Rundgang, lässt wartende Abläufe zu Ende laufen und schließt erst dann Datenbank und Fenster; mehrfacher Aufruf unschädlich - Fehler-Handler protokolliert immer, zeigt aber keine Meldung mehr ohne Fenster - Alt+F4 schließt nur das aktive bzw. modale Fenster (nicht mehr das ganze Programm) - Betrag ändern: Ersatz-Dialog, wenn die Zelle nicht sichtbar ist (vorher passierte still nichts) - tests/test_gui.py: Oberflächen-Tests inkl. Schließen bei jedem Dialog, Watchdog gegen Hänger - Build: ein Xvfb für den ganzen Build (Wine bindet sich an das erste DISPLAY), GUI-Tests unter Windows-Python, danach Selbsttest der fertigen .exe (--selbsttest) Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 + README.md | 10 +- build/build_windows.sh | 30 ++- provtool/__init__.py | 2 +- provtool/gui/app.py | 104 ++++++++- provtool/gui/assistent.py | 13 +- provtool/gui/rahmen.py | 23 +- provtool/gui/win95.py | 3 + provtool/selbsttest.py | 172 +++++++++++++++ tests/test_gui.py | 450 ++++++++++++++++++++++++++++++++++++++ 10 files changed, 795 insertions(+), 15 deletions(-) create mode 100644 provtool/selbsttest.py create mode 100644 tests/test_gui.py diff --git a/.gitignore b/.gitignore index 8545d8a..0f7b48c 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ dist/ build/tmp/ build/version_info.txt *.db +selbsttest.txt +/*.jpg +/*.png diff --git a/README.md b/README.md index d5b7814..50cd282 100644 --- a/README.md +++ b/README.md @@ -97,9 +97,17 @@ Aufbau: Gitea Actions (`.gitea/workflows/windows-build.yml`) baut die `.exe` im Container `tobix/pywine:3.12` (Windows-Python unter Wine) mit PyInstaller: -- jeder Push: Tests unter Windows-Python + `Provisionsmanager95.exe` als Artefakt +- jeder Push: alle Tests unter Windows-Python (Wine) **inklusive Oberflächen-Tests** auf einem + virtuellen Bildschirm, danach `Provisionsmanager95.exe` bauen und mit + `Provisionsmanager95.exe --selbsttest` die **fertige .exe** prüfen; erst dann gibt es das Artefakt - Tag `vX.Y.Z` (passend zu `provtool/__init__.py`): zusätzlich ein Gitea-Release mit `.exe` und `.zip` +Die Oberflächen-Tests (`tests/test_gui.py`) bedienen die echte Oberfläche: Dialoge öffnen, +Programm schließen, während Assistent/Rückfragen/Rundgang offen sind, Alt+F4, Beträge ändern usw. +Jede Ausnahme in der Oberfläche und jeder Eintrag in `fehler.log` lässt sie fehlschlagen; ein +Watchdog bricht hängende Tests ab, statt den Build zu blockieren. Ohne Bildschirm werden sie lokal +übersprungen, im Build (`PROVTOOL_GUI_TESTS=1`) ist das ein Fehler. + Lokal geht dasselbe mit Docker: ```bash diff --git a/build/build_windows.sh b/build/build_windows.sh index aef41d8..d5dc3df 100755 --- a/build/build_windows.sh +++ b/build/build_windows.sh @@ -9,11 +9,24 @@ export LANG=C.UTF-8 LC_ALL=C.UTF-8 # sonst kann Wine keine Umlaute in Dateinam export WINEPREFIX="${WINEPREFIX:-/opt/wineprefix}" PY="wine python" +# Ein virtueller Bildschirm für den GANZEN Build – vor dem ersten Wine-Aufruf starten! +# Wine bindet seinen Hintergrundprozess (wineserver/Desktop) an das DISPLAY des ersten Aufrufs. +# Läuft z. B. „pip install“ ohne Bildschirm und die GUI-Tests später mit, scheitert Tk unter +# Wine mit „Failed to create the menu window“. +wineserver -k 2>/dev/null || true +ANZEIGE=":$((90 + RANDOM % 9))" +Xvfb "$ANZEIGE" -screen 0 1280x800x24 -nolisten tcp >/dev/null 2>&1 & +XVFB_PID=$! +trap 'wineserver -k 2>/dev/null || true; kill $XVFB_PID 2>/dev/null || true' EXIT +export DISPLAY="$ANZEIGE" +sleep 1 + $PY --version $PY -m pip install --disable-pip-version-check -q -r requirements-dev.txt -echo "== Tests (Windows-Python) ==" -$PY -m pytest -q +echo "== Tests inkl. Oberfläche (Windows-Python unter Wine) ==" +# PROVTOOL_GUI_TESTS=1: fehlt der Bildschirm, ist das ein Fehler (nicht „übersprungen“) +PROVTOOL_GUI_TESTS=1 $PY -m pytest -q -p no:cacheprovider echo "== Version ==" VERSION=$($PY build/version_info.py | tr -d '\r') @@ -23,6 +36,19 @@ echo "== PyInstaller ==" $PY -m PyInstaller --noconfirm --clean --distpath dist --workpath build/tmp build/provisionsmanager.spec test -f dist/Provisionsmanager95.exe + +echo "== Selbsttest der fertigen .exe ==" +rm -f selbsttest.txt +set +e +timeout 300 wine dist/Provisionsmanager95.exe --selbsttest selbsttest.txt +CODE=$? +set -e +cat selbsttest.txt 2>/dev/null || echo "(keine Ergebnisdatei – die .exe ist vermutlich gar nicht gestartet)" +if [ "$CODE" -ne 0 ] || ! head -1 selbsttest.txt 2>/dev/null | tr -d '\r' | grep -qx OK; then + echo "Selbsttest der .exe FEHLGESCHLAGEN (Exit-Code $CODE)" + exit 1 +fi +echo "Selbsttest bestanden" cp README.md dist/LIESMICH.md 2>/dev/null || true ( cd dist && rm -f Provisionsmanager95-*.zip && \ python3 -c "import zipfile,sys; z=zipfile.ZipFile(sys.argv[1],'w',zipfile.ZIP_DEFLATED); [z.write(f) for f in sys.argv[2:]]; z.close()" \ diff --git a/provtool/__init__.py b/provtool/__init__.py index 44a64cc..c72c481 100644 --- a/provtool/__init__.py +++ b/provtool/__init__.py @@ -1,5 +1,5 @@ """Provisions-Manager 95 – Provisionsabrechnung für Unterhändler der Handy-Insel Miesbach.""" -__version__ = "1.1.0" +__version__ = "1.1.1" APP_NAME = "Provisions-Manager 95" APP_ORG = "Handy-Insel Miesbach" diff --git a/provtool/gui/app.py b/provtool/gui/app.py index d992327..65291b0 100644 --- a/provtool/gui/app.py +++ b/provtool/gui/app.py @@ -64,6 +64,8 @@ class App: self.haendler_namen: dict[int, str] = {} self.ansicht: int = ALLE self._edit: tk.Entry | None = None + self._schliesst = False # Beenden angefordert (offene Dialoge werden gerade geschlossen) + self._beendet = False # Datenbank und Fenster sind zu w.apply(root) self.icons = w.Icons(root) @@ -594,10 +596,12 @@ class App: self.ptree.see(iid) self.ptree.update_idletasks() bbox = self.ptree.bbox(iid, "ausz") - if not bbox: - return pid = int(iid[1:]) p = next(x for x in self.positionen if x["id"] == pid) + if not bbox: + # Zelle gerade nicht sichtbar (z. B. Spalte zu schmal) → kleiner Dialog statt Inline-Feld + self._betrag_dialog(p) + return x, y, bw, bh = bbox var = tk.StringVar(value=fmt(p["betrag"], symbol=False)) e = tk.Entry(self.ptree, textvariable=var, justify="right", bd=1, relief="solid", font=w.FONT) @@ -646,6 +650,25 @@ class App: e.bind("", abbruch) e.bind("", speichern) + def _betrag_dialog(self, p: dict): + kunde = " ".join(x for x in (p["vorname"], p["name"]) if x) or "(ohne Name)" + while True: + text = w.eingabe(self.root, "Betrag ändern", + f"Auszahlung für {kunde}\n{p['provisionsart']} – Provision {fmt(p['betrag_orig'])}:", + fmt(p["betrag"], symbol=False)) + if text is None: + return + try: + cent = parse_cent(text) + except ValueError: + w.warnung(self.root, "Betrag ändern", "Bitte einen Betrag eingeben, z. B. 25 oder 12,50.") + continue + break + if cent != p["betrag"]: + self.db.betrag_setzen(p["id"], cent) + self.db.touch(self.aid) + self._aktualisieren() + def _kontextmenue(self, e): iid = self.ptree.identify_row(e.y) if not iid: @@ -806,6 +829,8 @@ class App: def assistent(self): res = ass.Einrichtungsassistent(self).zeigen() + if self._schliesst: + return # Programm wird gerade beendet – beim nächsten Start erscheint der Assistent erneut self.settings["assistent_erledigt"] = True config.save(self.settings) self._aktualisieren() @@ -821,6 +846,8 @@ class App: self.root.after(200, self.rundgang) def rundgang(self): + if self._schliesst: + return tb = self.tb schritte = [ (self.rahmen.titelleiste or self.menueleiste, "Willkommen zum Rundgang!", @@ -868,24 +895,85 @@ class App: text = "".join(traceback.format_exception(exc, val, tb)) try: with (config.app_dir() / "fehler.log").open("a", encoding="utf-8") as f: - f.write(text + "\n") + f.write(f"--- {date.today().isoformat()} {APP_NAME}\n{text}\n") except OSError: pass - w.fehler(self.root, "Allgemeine Schutzverletzung", - f"Im Programm ist ein Fehler aufgetreten:\n\n{val}\n\n" - f"Details stehen in {config.app_dir() / 'fehler.log'}") + # Beim/nach dem Beenden keine Meldung mehr zeigen – das Fenster gibt es dann nicht mehr + if self._beendet or self._schliesst or not self._lebt(): + return + try: + w.fehler(self.root, "Allgemeine Schutzverletzung", + f"Im Programm ist ein Fehler aufgetreten:\n\n{val}\n\n" + f"Details stehen in {config.app_dir() / 'fehler.log'}") + except tk.TclError: + pass + + def _lebt(self) -> bool: + try: + return bool(self.root.winfo_exists()) + except tk.TclError: + return False + + def _offene_fenster(self) -> list[tk.Toplevel]: + """Alle offenen Dialoge (auch verschachtelte), innerste zuletzt.""" + out: list[tk.Toplevel] = [] + + def suche(widget): + for kind in widget.winfo_children(): + if isinstance(kind, tk.Toplevel): + out.append(kind) + suche(kind) + if self._lebt(): + suche(self.root) + return out def beenden(self): + """Programm schließen – auch wenn gerade Dialoge, Rückfragen oder der Rundgang offen sind. + + Offene Dialoge werden zuerst geschlossen; der Code, der auf sie gewartet hat, läuft dann mit + „abgebrochen“ zu Ende (Datenbank noch offen). Neue Dialoge öffnen sich in dieser Phase nicht + mehr. Erst danach werden Einstellungen gespeichert, Datenbank und Fenster geschlossen.""" + if self._beendet or not self._lebt(): + return + self._schliesst = True + self.root._provtool_schliesst = True # type: ignore[attr-defined] # Signal an win95.Dialog + rg = getattr(self, "_rundgang", None) + if rg is not None: + rg.beenden() + if self._edit is not None: + self._edit.destroy() + self._edit = None + offen = self._offene_fenster() + if offen: + for t in reversed(offen): + try: + if t.winfo_exists(): + t.destroy() + except tk.TclError: + pass + self.root.after(30, self.beenden) # wenn alle wartenden Abläufe zurückgekehrt sind + return + self._beendet = True try: self.settings["fenster"] = self.rahmen.normale_geometrie() self.settings["maximiert"] = self.rahmen.maximiert config.save(self.settings) - self.db.close() + except (tk.TclError, OSError): + pass finally: - self.root.destroy() + try: + self.db.close() + finally: + try: + self.root.destroy() + except tk.TclError: + pass def main(): + if len(sys.argv) > 1 and sys.argv[1] == "--selbsttest": + from provtool.selbsttest import ausfuehren + sys.exit(ausfuehren(sys.argv[2] if len(sys.argv) > 2 else "selbsttest.txt")) if sys.platform.startswith("win"): try: # scharfe Darstellung auf HiDPI-Bildschirmen import ctypes diff --git a/provtool/gui/assistent.py b/provtool/gui/assistent.py index a760ae7..db83167 100644 --- a/provtool/gui/assistent.py +++ b/provtool/gui/assistent.py @@ -410,14 +410,20 @@ class Rundgang: def _weg(self): for r in self.rahmen: - r.destroy() + try: + r.destroy() + except tk.TclError: + pass self.rahmen = [] if self.blase is not None: try: self.blase.grab_release() except tk.TclError: pass - self.blase.destroy() + try: + self.blase.destroy() + except tk.TclError: + pass self.blase = None def beenden(self): @@ -434,6 +440,8 @@ class Rundgang: def _zeige(self, nr: int): self._weg() + if getattr(self.root, "_provtool_schliesst", False): + return self.nr = nr ziel, titel, text = self.schritte[nr] if callable(ziel) and not isinstance(ziel, tk.Misc): @@ -444,6 +452,7 @@ class Rundgang: self._markieren(x, y, bw, bh) b = self.blase = tk.Toplevel(self.root, bg=w.DARK) + b.abbrechen = self.beenden # Alt+F4 auf der Sprechblase beendet den Rundgang sauber b.withdraw() b.overrideredirect(True) b.transient(self.root) diff --git a/provtool/gui/rahmen.py b/provtool/gui/rahmen.py index 6d6a7cd..70e335e 100644 --- a/provtool/gui/rahmen.py +++ b/provtool/gui/rahmen.py @@ -255,9 +255,30 @@ class Hauptrahmen: self.aussen.bind("", lambda e: self.aussen.configure(cursor="")) self.aussen.bind("", self._resize_start) self.aussen.bind("", self._resize) - root.bind_all("", lambda e: schliessen()) + root.bind_all("", lambda e: self.alt_f4(e.widget)) root.after(50, lambda: taskleiste_anzeigen(root)) + def alt_f4(self, widget) -> str: + """Alt+F4 wie unter Windows: schließt nur das Fenster, in dem man gerade ist.""" + try: + # Ist ein modaler Dialog offen, gilt Alt+F4 immer ihm – egal wo der Tastaturfokus liegt + # (unter Windows liefert Tk Tastendrücke an das Fenster mit Fokus, das kann noch das + # Hauptfenster sein). + sperre = self.root.grab_current() + if sperre is not None and sperre.winfo_toplevel() is not self.root: + fenster = sperre.winfo_toplevel() + else: + fenster = widget.winfo_toplevel() if isinstance(widget, tk.Misc) else self.root + except tk.TclError: + fenster = self.root + if fenster is self.root: + self._schliessen() + elif hasattr(fenster, "abbrechen"): + fenster.abbrechen() + else: + fenster.destroy() + return "break" + # ---- öffentliche Helfer def set_titel(self, text: str): self.root.title(text) diff --git a/provtool/gui/win95.py b/provtool/gui/win95.py index 9f8ae00..bd93e16 100644 --- a/provtool/gui/win95.py +++ b/provtool/gui/win95.py @@ -526,6 +526,9 @@ class Dialog(tk.Toplevel): self.titelleiste.pack(side="top", fill="x") def zeigen(self, fokus=None): + if getattr(self._root(), "_provtool_schliesst", False): + self.destroy() # Programm wird beendet – keine neuen Dialoge mehr + return None self.update_idletasks() m = self.master.winfo_toplevel() w, h = self.winfo_reqwidth(), self.winfo_reqheight() diff --git a/provtool/selbsttest.py b/provtool/selbsttest.py new file mode 100644 index 0000000..51ac9d6 --- /dev/null +++ b/provtool/selbsttest.py @@ -0,0 +1,172 @@ +"""Selbsttest der fertigen Programmdatei: `Provisionsmanager95.exe --selbsttest ergebnis.txt` + +Läuft im Build (Wine + virtueller Bildschirm) gegen die tatsächlich ausgelieferte .exe. Startet die +echte Oberfläche in einem Wegwerf-Profil, spielt kritische Abläufe durch und schreibt „OK“ bzw. die +gefundenen Fehler in die Ergebnisdatei (Exit-Code 0 = bestanden). + +Geprüft wird u. a. der Fall „Programm schließen, während der Einrichtungs-Assistent offen ist“ +sowie Import und Export (openpyxl/xlrd müssen in der .exe enthalten sein). +""" +from __future__ import annotations + +import os +import sys +import tempfile +import time +import tkinter as tk +import traceback +from pathlib import Path + +ZEITLIMIT = 60 # Sekunden je Szenario + + +class _Szenario: + def __init__(self, name: str, home: Path, einstellungen: str | None = None): + self.name, self.home, self.fehler = name, home, [] + home.mkdir(parents=True, exist_ok=True) + os.environ["PROVTOOL_HOME"] = str(home) + if einstellungen: + (home / "einstellungen.json").write_text(einstellungen, encoding="utf-8") + from provtool.gui.app import App + self.root = tk.Tk() + self.app = App(self.root) + echt = self.root.report_callback_exception + + def melden(exc, val, tb): + self.fehler.append("".join(traceback.format_exception(exc, val, tb))) + try: + echt(exc, val, tb) + except Exception: + self.fehler.append("Fehler-Handler ist abgestürzt:\n" + traceback.format_exc()) + self.root.report_callback_exception = melden + + def lebt(self) -> bool: + try: + return bool(self.root.winfo_exists()) + except tk.TclError: + return False + + def dialog(self, typ): + from provtool.gui import win95 as w + gefunden = [] + + def suche(widget): + for kind in widget.winfo_children(): + if isinstance(kind, w.Dialog) and isinstance(kind, typ) and kind.winfo_exists(): + gefunden.append(kind) + suche(kind) + if self.lebt(): + suche(self.root) + return gefunden[0] if gefunden else None + + def ablauf(self, schritte): + offen, start = list(schritte), time.time() + + def poll(): + if not self.lebt(): + return + if time.time() - start > ZEITLIMIT: + self.fehler.append(f"Zeitüberschreitung bei: {offen[0][2] if offen else 'Ende'}") + try: + self.root.destroy() + except tk.TclError: + pass + return + if offen: + try: + bereit = offen[0][0]() + except Exception: + bereit = False + if bereit: + _, aktion, _ = offen.pop(0) + + def ausfuehren(a=aktion): + try: + a() + except Exception: + self.fehler.append(traceback.format_exc()) + self.root.after(0, ausfuehren) + self.root.after(20, poll) + + self.root.after(20, poll) + self.root.mainloop() + if offen: + self.fehler.append("Nicht ausgeführt: " + ", ".join(s[2] for s in offen)) + log = self.home / "fehler.log" + if log.exists() and log.read_text(encoding="utf-8").strip(): + self.fehler.append("fehler.log:\n" + log.read_text(encoding="utf-8")) + return [f"[{self.name}] {f}" for f in self.fehler] + + +def _beispiel_csv(pfad: Path): + kopf = ("Zeitraum;Verguetungsart;Haendlernummer;Unterhaendler;Vertragsnummer;Aktiv;Deaktiv;Rufnummer;" + "Kartennummer;IMEI_SN;Geraetename;Kundenvorname;Kundename;Tarif-Kurz;Tarif;Prozent;Betrag") + zeilen = [kopf, + "2026.10.01-2026.10.07;SIM-Karten;1;111;1;05.10.2026;;1601111111;;;;;Muster GmbH;;Allnet;0;8", + "2026.10.01-2026.10.07;Sonderbonus Aktion;1;111;2;06.10.2026;;1602222222;;;;;Beispiel KG;;Classic;0;170"] + pfad.write_bytes(("\r\n".join(zeilen) + "\r\n").encode("cp1252")) + + +def ausfuehren(ergebnisdatei: str) -> int: + from provtool.gui.assistent import Einrichtungsassistent + basis = Path(tempfile.mkdtemp(prefix="pm95-selbsttest-")) + fehler: list[str] = [] + immer = lambda: True # noqa: E731 + try: + # 1) Gemeldeter Absturz: Schließen, während der Einrichtungs-Assistent offen ist + s = _Szenario("Schließen bei offenem Assistenten", basis / "a") + cmd = s.root.tk.call("wm", "protocol", s.root._w, "WM_DELETE_WINDOW") + fehler += s.ablauf([(lambda: s.dialog(Einrichtungsassistent) is not None, lambda: None, "Assistent erscheint"), + (immer, lambda: s.root.tk.call(cmd), "Fenster schließen")]) + + # 2) Assistent komplett, Import, Export, Rundgang, Beenden + csv = basis / "freenet.csv" + _beispiel_csv(csv) + s = _Szenario("Assistent, Import, Export", basis / "b") + wiz = lambda: s.dialog(Einrichtungsassistent) # noqa: E731 + seite = lambda n: (lambda: wiz() is not None and wiz().nr == n) # noqa: E731 + + def haendler(): + wiz().v_name.set("Selbsttest-Händler") + wiz().v_fn.set("111") + wiz()._haendler_dazu() + + def abrechnung(): + wiz().dateien.append(str(csv)) + wiz().v_rundgang.set(False) + wiz()._weiter() + + def import_bericht_zu(): + from provtool.gui.dialogs import ImportBericht + s.dialog(ImportBericht).abbrechen() + + def export(): + from provtool import export as exp + app = s.app + pos = [p for p in app.positionen if p["haendler_id"] > 0] + dateien = exp.exportiere_haendler(basis / "export", "Selbsttest", pos, "Test") + if len(pos) != 2 or len(dateien) != 1 or not dateien[0].exists(): + s.fehler.append(f"Export fehlerhaft: {len(pos)} Positionen, Dateien {dateien}") + + from provtool.gui.dialogs import ImportBericht + fehler += s.ablauf([ + (seite(0), lambda: wiz()._weiter(), "Willkommen"), + (seite(1), lambda: wiz()._weiter(), "Speicherort"), + (seite(2), haendler, "Händler anlegen"), + (lambda: len(s.app.db.haendler_liste()) == 1, lambda: wiz()._weiter(), "Händler → weiter"), + (seite(3), abrechnung, "Abrechnung mit Datei"), + (seite(4), lambda: wiz()._weiter(), "Fertig stellen"), + (lambda: s.dialog(ImportBericht) is not None, import_bericht_zu, "Import-Bericht schließen"), + (lambda: len(s.app.positionen) == 2, export, "Export"), + (immer, s.app.rundgang, "Rundgang"), + (lambda: getattr(s.app, "_rundgang", None) is not None, s.app.beenden, "Beenden während Rundgang"), + ]) + except Exception: + fehler.append(traceback.format_exc()) + + Path(ergebnisdatei).write_text("OK\n" if not fehler else "FEHLER\n\n" + "\n\n".join(fehler), encoding="utf-8") + return 0 if not fehler else 1 + + +if __name__ == "__main__": + sys.exit(ausfuehren(sys.argv[1] if len(sys.argv) > 1 else "selbsttest.txt")) diff --git a/tests/test_gui.py b/tests/test_gui.py new file mode 100644 index 0000000..3ea5a53 --- /dev/null +++ b/tests/test_gui.py @@ -0,0 +1,450 @@ +"""GUI-Tests: bedienen die echte Oberfläche (unter Xvfb bzw. Wine+Xvfb im Build). + +Jede Ausnahme in einem Tk-Callback, jeder Absturz des Fehler-Handlers und jeder Eintrag in +fehler.log lässt den Test fehlschlagen. Modale Dialoge blockieren eine eigene Ereignisschleife – +deshalb laufen die Testschritte als Tk-Callbacks (Schrittplan) und ein Watchdog bricht hängende +Tests nach einer Zeitgrenze ab, statt den Build zu blockieren. + +Ohne Bildschirm werden die Tests übersprungen – außer PROVTOOL_GUI_TESTS=1 ist gesetzt (Build). +""" +from __future__ import annotations + +import json +import os +import time +import tkinter as tk +import traceback + +import pytest + +from provtool import config +from provtool.db import Datenbank, Haendler + +PFLICHT = os.environ.get("PROVTOOL_GUI_TESTS") == "1" +IMMER = lambda: True # noqa: E731 + + +class Lauf: + """Startet die App in einem eigenen Profil, führt Schrittpläne aus und sammelt alle Fehler.""" + + def __init__(self, home, einstellungen: dict | None = None): + self.home = home + if einstellungen is not None: + (home / "einstellungen.json").write_text(json.dumps(einstellungen), encoding="utf-8") + self.root = None + for versuch in range(3): + try: + self.root = tk.Tk() + break + except tk.TclError as exc: + # Unter Wine scheitert das Anlegen vieler Tk-Instanzen in einem Prozess gelegentlich + # mit „couldn't read file init.tcl: Success“ – das betrifft nur die Tests (die App + # legt genau eine Instanz an). Daher kurz warten und erneut versuchen. + if "init.tcl" in str(exc) and versuch < 2: + time.sleep(0.5) + continue + if PFLICHT: + raise + pytest.skip(f"kein Bildschirm: {exc}") + self.fehler: list[str] = [] + from provtool.gui import app as A + self.app = A.App(self.root) + echt = self.root.report_callback_exception + + def melden(exc, val, tb): + self.fehler.append("".join(traceback.format_exception(exc, val, tb))) + try: + echt(exc, val, tb) # der echte Fehler-Handler darf dabei selbst nicht abstürzen + except Exception: + self.fehler.append("Fehler-Handler ist abgestürzt:\n" + traceback.format_exc()) + # eine evtl. geöffnete Fehlermeldung schließen, damit der Test nicht hängt + self.root.after(100, self._meldungen_zu) + self.root.report_callback_exception = melden + + # ---------------------------------------------------------------- Zustand + @property + def lebt(self) -> bool: + try: + return bool(self.root.winfo_exists()) + except tk.TclError: + return False + + def dialoge(self) -> list[tk.Toplevel]: + from provtool.gui import win95 as w + out = [] + + def suche(widget): + try: + kinder = widget.winfo_children() + except tk.TclError: + return + for kind in kinder: + if isinstance(kind, w.Dialog) and kind.winfo_exists(): + out.append(kind) + suche(kind) + if self.lebt: + suche(self.root) + return out + + def dialog(self, typ): + return next((d for d in self.dialoge() if isinstance(d, typ)), None) + + def _meldungen_zu(self): + for d in reversed(self.dialoge()): + if d.winfo_exists() and d.title() == "Allgemeine Schutzverletzung": + d.destroy() + + def _notaus(self): + for d in reversed(self.dialoge()): + try: + d.destroy() + except tk.TclError: + pass + try: + self.root.destroy() + except tk.TclError: + pass + + # ---------------------------------------------------------------- Ablauf + def ablauf(self, schritte: list[tuple], sekunden: float = 20.0, nachlauf: float = 0.3): + """schritte: [(bedingung, aktion, beschreibung), ...] – jede Aktion läuft als eigener + Tk-Callback, sobald ihre Bedingung erfüllt ist (auch während ein modaler Dialog offen ist).""" + offen = list(schritte) + start = time.time() + zustand = {"fertig_seit": None} + + def poll(): + if not self.lebt: + return + if time.time() - start > sekunden: + text = offen[0][2] if offen else "(nach dem letzten Schritt hängt ein Dialog)" + self.fehler.append(f"Zeitüberschreitung – hängt bei: {text}") + self._notaus() + return + if offen: + bed, akt, _ = offen[0] + try: + bereit = bed() + except Exception: + bereit = False + if bereit: + offen.pop(0) + self.root.after(0, akt) + elif zustand["fertig_seit"] is None: + zustand["fertig_seit"] = time.time() + self.root.after(20, poll) + + self.root.after(20, poll) + while self.lebt: + try: + self.root.update() + except tk.TclError: + break + f = zustand["fertig_seit"] + if f is not None and time.time() - f >= nachlauf: + break + time.sleep(0.01) + self.uebrig = [s[2] for s in offen] + + def schliessen_ueber_fenster(self): + """Wie Klick auf × / Taskleiste ▸ Fenster schließen (WM_DELETE_WINDOW).""" + cmd = self.root.tk.call("wm", "protocol", self.root._w, "WM_DELETE_WINDOW") + self.root.tk.call(cmd) + + def pruefen(self): + log = self.home / "fehler.log" + inhalt = log.read_text(encoding="utf-8") if log.exists() else "" + assert not self.fehler, "Fehler in der Oberfläche:\n\n" + "\n".join(self.fehler) + assert not inhalt, "fehler.log wurde geschrieben:\n" + inhalt + + def ende(self): + if self.lebt: + self.ablauf([(IMMER, self.app.beenden, "beenden")], sekunden=10) + + +@pytest.fixture +def home(tmp_path, monkeypatch): + monkeypatch.setenv("PROVTOOL_HOME", str(tmp_path)) + return tmp_path + + +@pytest.fixture +def lauf_mit_daten(home): + """App mit einem Händler und einer Abrechnung (erfundene Daten).""" + from tests.test_kern import FREENET_KOPF, freenet_zeile + csv = home / "freenet.csv" + csv.write_bytes(("\r\n".join([ + FREENET_KOPF, + freenet_zeile("SIM-Karten", "111", "05.10.2026", "1601111111", "Muster GmbH", "Allnet", "8"), + freenet_zeile("Sonderbonus Aktion", "111", "06.10.2026", "1602222222", "Beispiel KG", "Classic", "170"), + freenet_zeile("SIM-Karten", "999", "07.10.2026", "1603333333", "Eigener Kunde", "Flex", "8"), + ]) + "\r\n").encode("cp1252")) + db = Datenbank(config.db_path()) + db.haendler_speichern(Haendler(0, "Händler A"), [("freenet", "111")]) + from provtool.importer import lies_datei + db.importieren(db.abrechnung_neu("Oktober 2026"), lies_datei(csv)) + db.close() + lauf = Lauf(home, {"assistent_erledigt": True, "tipps_beim_start": False}) + yield lauf + lauf.ende() + + +def _assistent_offen(lauf): + from provtool.gui.assistent import Einrichtungsassistent + return lambda: lauf.dialog(Einrichtungsassistent) is not None + + +# =========================================================================== Schließen bei offenem Dialog + +@pytest.mark.parametrize("weg", ["fenster", "beenden"]) +def test_schliessen_waehrend_assistent(home, weg): + """Reproduziert den gemeldeten Absturz: Programm schließen, während der Assistent offen ist.""" + lauf = Lauf(home) # frisches Profil → Assistent startet automatisch + zu = {"fenster": lauf.schliessen_ueber_fenster, "beenden": lauf.app.beenden}[weg] + lauf.ablauf([(_assistent_offen(lauf), lambda: None, "Assistent erscheint"), + (IMMER, zu, f"schließen über {weg}")]) + assert not lauf.lebt, "Programm wurde nicht beendet" + lauf.pruefen() + + +def test_alt_f4_im_assistent_fragt_und_schliesst_nur_assistent(home): + """Alt+F4 bei offenem Assistenten = „Abbrechen“ mit Rückfrage; das Programm bleibt offen.""" + from provtool.gui.assistent import Einrichtungsassistent + lauf = Lauf(home) + rueckfrage = lambda: next((d for d in lauf.dialoge() if d.title() == "Einrichtung abbrechen"), None) # noqa: E731 + + def ja_klicken(): + d = rueckfrage() + d.result = True + d.destroy() + + lauf.ablauf([ + (_assistent_offen(lauf), lambda: lauf.dialog(Einrichtungsassistent).event_generate(""), "Alt+F4"), + (lambda: rueckfrage() is not None, ja_klicken, "Rückfrage mit Ja beantworten"), + (lambda: lauf.dialog(Einrichtungsassistent) is None, lambda: None, "Assistent ist zu"), + ]) + assert lauf.lebt, "Programm darf nicht beendet werden" + assert not lauf.uebrig, lauf.uebrig + assert lauf.app.settings.get("assistent_erledigt") is True + lauf.pruefen() + lauf.ende() + lauf.pruefen() + + +def test_alt_f4_im_dialog_schliesst_nur_den_dialog(lauf_mit_daten): + from provtool.gui.dialogs import InfoDialog + lauf = lauf_mit_daten + lauf.ablauf([ + (IMMER, lambda: InfoDialog(lauf.root).zeigen(), "Info öffnen"), + (lambda: lauf.dialog(InfoDialog) is not None, + lambda: lauf.dialog(InfoDialog).event_generate(""), "Alt+F4 im Dialog"), + (lambda: lauf.dialog(InfoDialog) is None, lambda: None, "Dialog geschlossen"), + ], sekunden=8) + assert lauf.lebt, "Alt+F4 im Dialog darf nicht das ganze Programm beenden" + assert not lauf.uebrig, lauf.uebrig + lauf.pruefen() + + +def _dialog_oeffner(): + from provtool.gui import dialogs as dlg + return { + "haendler": lambda a: a.haendler(), + "abrechnungen": lambda a: a.abrechnung_waehlen(), + "export": lambda a: a.exportieren(), + "einstellungen": lambda a: a.einstellungen(), + "neu": lambda a: a.neu(), + "info": lambda a: dlg.InfoDialog(a.root).zeigen(), + "tipp": lambda a: a.tipp_des_tages(), + "dateien": lambda a: a.dateien(), + "hilfe": lambda a: a.hilfe(), + "assistent": lambda a: a.assistent(), + "prozent": lambda a: (a.ptree.selection_set(a.ptree.get_children()[0]), a.prozent()), + "neu_berechnen": lambda a: (a.db.betrag_setzen(a.positionen[0]["id"], 1), a._aktualisieren(), + a.neu_berechnen()), + } + + +@pytest.mark.parametrize("name", ["haendler", "abrechnungen", "export", "einstellungen", "neu", "info", "tipp", + "dateien", "hilfe", "assistent", "prozent", "neu_berechnen"]) +def test_schliessen_waehrend_dialog_offen(lauf_mit_daten, name): + lauf = lauf_mit_daten + oeffnen = _dialog_oeffner()[name] + lauf.ablauf([(IMMER, lambda: oeffnen(lauf.app), f"{name} öffnen"), + (lambda: bool(lauf.dialoge()), lauf.schliessen_ueber_fenster, "Programm schließen")]) + assert not lauf.lebt, f"Programm wurde nicht beendet (offen: {lauf.uebrig})" + lauf.pruefen() + + +def test_schliessen_waehrend_rueckfrage_im_assistent(home): + """Assistent offen → „Abbrechen“ → Rückfrage offen → Programm schließen.""" + from provtool.gui.assistent import Einrichtungsassistent + lauf = Lauf(home) + lauf.ablauf([ + (_assistent_offen(lauf), lambda: lauf.dialog(Einrichtungsassistent).abbrechen(), "Abbrechen klicken"), + (lambda: len(lauf.dialoge()) >= 2, lauf.schliessen_ueber_fenster, "Programm schließen"), + ]) + assert not lauf.lebt + lauf.pruefen() + + +def test_schliessen_waehrend_rundgang(lauf_mit_daten): + lauf = lauf_mit_daten + lauf.ablauf([ + (IMMER, lauf.app.rundgang, "Rundgang starten"), + (lambda: getattr(lauf.app, "_rundgang", None) and lauf.app._rundgang.blase, lauf.schliessen_ueber_fenster, + "Programm schließen"), + ]) + assert not lauf.lebt + lauf.pruefen() + + +def test_alt_f4_im_rundgang_beendet_nur_den_rundgang(lauf_mit_daten): + lauf = lauf_mit_daten + rg = lambda: getattr(lauf.app, "_rundgang", None) # noqa: E731 + lauf.ablauf([ + (IMMER, lauf.app.rundgang, "Rundgang starten"), + (lambda: rg() is not None and rg().blase is not None, + lambda: rg().blase.event_generate(""), "Alt+F4"), + (lambda: rg().blase is None and not rg().rahmen, lambda: None, "Rundgang samt Rahmen weg"), + ], sekunden=8) + assert lauf.lebt, "Alt+F4 im Rundgang darf nicht das Programm beenden" + assert not lauf.uebrig, lauf.uebrig + lauf.pruefen() + + +def test_betrag_dialog_wenn_zelle_unsichtbar(lauf_mit_daten): + """Ist die Betragszelle nicht sichtbar, öffnet sich ein Dialog statt still nichts zu tun.""" + lauf = lauf_mit_daten + app = lauf.app + hid = next(h.id for h in app.db.haendler_liste()) + eingabe = lambda: next((d for d in lauf.dialoge() if d.title() == "Betrag ändern"), None) # noqa: E731 + + def tippen(): + d = eingabe() + entry = next(k for k in d.winfo_children() if isinstance(k, tk.Entry)) + entry.delete(0, "end") + entry.insert(0, "7,77") + d.result = entry.get() + d.destroy() + + def oeffnen(): + pos = app.ptree.get_children(app.ptree.get_children()[0])[0] + app.ptree.bbox = lambda *a, **k: "" # Zelle „unsichtbar“ + app.betrag_bearbeiten(pos) + + lauf.ablauf([ + (IMMER, lambda: app.htree.selection_set(str(hid)), "Händler wählen"), + (lambda: bool(app.ptree.get_children()), oeffnen, "Betrag ändern bei unsichtbarer Zelle"), + (lambda: eingabe() is not None, tippen, "Betrag im Dialog eingeben"), + ]) + assert not lauf.uebrig, lauf.uebrig + assert any(p["betrag"] == 777 and p["manuell"] for p in app.positionen) + del app.ptree.bbox + lauf.pruefen() + + +def test_fehler_handler_nach_zerstoertem_fenster(home): + """Der Fehler-Handler darf nicht abstürzen, wenn das Fenster schon weg ist – nur protokollieren.""" + lauf = Lauf(home, {"assistent_erledigt": True, "tipps_beim_start": False}) + app = lauf.app + lauf.root.destroy() + try: + raise RuntimeError("Testfehler") + except RuntimeError as exc: + app._fehler_handler(type(exc), exc, exc.__traceback__) # darf nicht werfen + assert "RuntimeError: Testfehler" in (home / "fehler.log").read_text(encoding="utf-8") + + +def test_zweimal_beenden(lauf_mit_daten): + lauf = lauf_mit_daten + lauf.ablauf([(IMMER, lambda: (lauf.app.beenden(), lauf.app.beenden()), "zweimal beenden")]) + assert not lauf.lebt + lauf.pruefen() + + +# =========================================================================== Rauchtests der Abläufe + +def test_assistent_komplett_durchklicken(home): + from provtool.gui.assistent import Einrichtungsassistent + lauf = Lauf(home) + wiz = lambda: lauf.dialog(Einrichtungsassistent) # noqa: E731 + + def haendler_eintragen(): + wiz().v_name.set("Testhändler") + wiz().v_fn.set("111 (3 Pos., 10,00 €)") + wiz().v_caps.set(True) + assert wiz()._haendler_dazu() + + seite = lambda n: (lambda: wiz() is not None and wiz().nr == n) # noqa: E731 + lauf.ablauf([ + (seite(0), lambda: wiz()._weiter(), "Willkommen → Speicherort"), + (seite(1), lambda: wiz()._weiter(), "Speicherort → Händler"), + (seite(2), haendler_eintragen, "Händler eintragen"), + (lambda: len(lauf.app.db.haendler_liste()) == 1, lambda: wiz()._zurueck(), "zurück"), + (seite(1), lambda: wiz()._weiter(), "wieder vor"), + (seite(2), lambda: wiz()._weiter(), "Händler → Abrechnung"), + (seite(3), lambda: wiz()._weiter(), "Abrechnung → Fertig"), + (seite(4), lambda: wiz()._weiter(), "Fertig stellen"), + (lambda: getattr(lauf.app, "_rundgang", None) is not None and lauf.app._rundgang.blase is not None, + lambda: [lauf.app._rundgang._weiter() for _ in range(len(lauf.app._rundgang.schritte))], + "Rundgang komplett durchklicken"), + ]) + assert not lauf.uebrig, f"nicht ausgeführt: {lauf.uebrig}" + db = lauf.app.db + h = db.haendler_liste() + assert [x.name for x in h] == ["Testhändler"] + assert ("vodafone_caps", "*") in db.kennungen(h[0].id) + assert [a["titel"] for a in db.abrechnungen()] + assert lauf.app._rundgang.blase is None + lauf.pruefen() + lauf.ende() + lauf.pruefen() + + +def test_hauptfenster_ablaeufe(lauf_mit_daten): + from provtool.gui.dialogs import ProzentDialog + lauf = lauf_mit_daten + app = lauf.app + hid = next(h.id for h in app.db.haendler_liste()) + + def betrag_aendern(): + # Feld öffnen und sofort tippen: ohne Fenstermanager (Xvfb/Wine) verliert das Feld sonst + # gleich den Fokus und speichert unverändert – genau wie beim Wegklicken. + pos = app.ptree.get_children(app.ptree.get_children()[0])[0] + app.betrag_bearbeiten(pos) + lauf.root.update_idletasks() + e = app._edit + e.focus_force() + e.delete(0, "end") + e.insert(0, "5,55") + e.event_generate("") + if app._edit is e: + # Unter Windows gehen erzeugte Tastendrücke an das Fenster mit Fokus – ohne Fenster- + # manager hat das Feld ihn evtl. nicht. Dann wie „wegklicken“ speichern (FocusOut). + e.event_generate("") + + def prozent_ok(): + d = lauf.dialog(ProzentDialog) + d.v.set("80") + d._ok() + + def fenster(): + if app.rahmen.klassisch: + app.rahmen.maximieren() + app.rahmen.wiederherstellen() + + lauf.ablauf([ + (IMMER, lambda: app.htree.selection_set(str(hid)), "Händler wählen"), + (lambda: bool(app.ptree.get_children()) + and bool(app.ptree.bbox(app.ptree.get_children(app.ptree.get_children()[0])[0], "ausz")), + betrag_aendern, "Betrag inline ändern (sobald die Zeile sichtbar ist)"), + (lambda: any(p["betrag"] == 555 and p["manuell"] for p in app.positionen), + lambda: (app.ptree.selection_set(app.ptree.get_children()[-1]), app.prozent()), "Prozent-Dialog"), + (lambda: lauf.dialog(ProzentDialog) is not None, prozent_ok, "80 % setzen"), + (lambda: lauf.dialog(ProzentDialog) is None, lambda: app.v_suche.set("Beispiel"), "suchen"), + (IMMER, lambda: app.v_suche.set(""), "Suche leeren"), + (IMMER, fenster, "maximieren/wiederherstellen"), + ]) + assert not lauf.uebrig, f"nicht ausgeführt: {lauf.uebrig}" + assert any(p["prozent"] == 80 for p in app.positionen) + lauf.pruefen()