Absturz beim Schließen mit offenem Dialog behoben; GUI-Tests und Selbsttest der .exe im Build

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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-11-02 10:55:00 +01:00
1 parent 4cfe0e6c47
commit aae28fa7f2
10 files changed
+795 -15

No files matched your search

+1 -1
View File
@@ -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"
+96 -8
View File
@@ -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("<Escape>", abbruch)
e.bind("<FocusOut>", 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
+11 -2
View File
@@ -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)
+22 -1
View File
@@ -255,9 +255,30 @@ class Hauptrahmen:
self.aussen.bind("<Leave>", lambda e: self.aussen.configure(cursor=""))
self.aussen.bind("<ButtonPress-1>", self._resize_start)
self.aussen.bind("<B1-Motion>", self._resize)
root.bind_all("<Alt-F4>", lambda e: schliessen())
root.bind_all("<Alt-F4>", 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)
+3
View File
@@ -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()
+172
View File
@@ -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"))