Absturz beim Schließen mit offenem Dialog behoben; GUI-Tests und Selbsttest der .exe im Build
Windows-Build / windows-exe (push) Successful in 2m52s
Windows-Build / windows-exe (push) Successful in 2m52s
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:
1 parent
6b520bce35
commit
0bcc22d8c9
10 files changed
+795
-15
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"))
|
||||
Reference in new issue
Block a user