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>
451 lines
18 KiB
Python
451 lines
18 KiB
Python
"""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()
|