Files
provisionsmanager95/tests/test_gui.py
T
adminandClaude Opus 5.5 aae28fa7f2 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>
2026-11-02 10:55:00 +01:00

451 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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>"), "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>"), "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>"), "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("<Return>")
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("<FocusOut>")
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()