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

+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