Files
provisionsmanager95/tests/test_gui.py
T

1195 lines
53 KiB
Python
Raw 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] = []
self.watchdog = False # True = der TEST hat wegen Zeitüberschreitung abgebrochen
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
self._sichtbar_seit: float | None = None
def hauptfenster_sichtbar(self) -> bool:
"""Wie ein Mensch erst bedienen, wenn das Hauptfenster zu sehen ist: Unter Windows wird es beim
Start für den Taskleisten-Eintrag kurz aus- und wieder eingeblendet (unter Wine langsamer).
Wird als erster Schritt jedes Ablaufs geprüft – NICHT mit einer eigenen update()-Schleife,
denn ein modaler Dialog (z. B. der Assistent beim Erststart) würde die festhalten."""
try:
sichtbar = bool(self.root.winfo_viewable()) and self.app.inhalt.winfo_height() > 50
lage = (self.root.winfo_rootx(), self.root.winfo_rooty(), self.root.winfo_width(),
self.root.winfo_height())
except tk.TclError:
return True
# erst wenn Lage und Größe einen Moment stabil sind (beim ersten Anzeigen gibt es unter Wine
# kurz einen Zwischenstand)
if not sichtbar or lage != getattr(self, "_lage", None):
self._lage = lage
self._sichtbar_seit = None
return False
if self._sichtbar_seit is None:
self._sichtbar_seit = time.time()
return time.time() - self._sichtbar_seit >= 0.15
# ---------------------------------------------------------------- Zustand
def lebt_sonst(self, text: str) -> str:
"""Fehlertext für „Programm ist zu“, der Watchdog-Abbrüche klar unterscheidet."""
if self.watchdog:
return "Zeitüberschreitung – der Test hat abgebrochen (nicht das Programm hat sich beendet):\n" \
+ "\n".join(self.fehler)
return text
@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):
self.watchdog = True
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 = [(self.hauptfenster_sichtbar, lambda: None, "Hauptfenster ist sichtbar")] + 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 diagnose(self) -> str:
"""Für Fehlermeldungen: welche Schritte nicht liefen + alle gesammelten Fehler."""
return f"nicht ausgeführt: {self.uebrig}\n" + "\n".join(self.fehler)
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
def _profil_mit_daten(home):
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()
@pytest.fixture
def lauf_mit_daten(home):
"""App mit einem Händler und einer Abrechnung (erfundene Daten), klassisches Win95-Design."""
_profil_mit_daten(home)
lauf = Lauf(home, {"assistent_erledigt": True, "tipps_beim_start": False, "design": "win95"})
yield lauf
lauf.ende()
@pytest.fixture
def lauf_modern(home):
"""Wie lauf_mit_daten, aber im modernen Design (Standard)."""
_profil_mit_daten(home)
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.app.rahmen.alt_f4(lauf.dialog(Einrichtungsassistent)), "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, lauf.lebt_sonst("Programm darf nicht beendet werden")
assert not lauf.uebrig, lauf.diagnose()
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, monkeypatch):
from provtool.gui import rahmen
from provtool.gui.dialogs import InfoDialog
lauf = lauf_mit_daten
protokoll: list[str] = [] # Diagnose: wer hat Alt+F4 bzw. Beenden ausgelöst?
alt_f4, beenden = rahmen.Hauptrahmen.alt_f4, type(lauf.app).beenden
def alt_f4_spion(self, widget):
protokoll.append(f"alt_f4(widget={widget}, sperre={self.root.grab_current()}, "
f"popup={self._offenes_popup()}, fokus={self.root.focus_get()})\n"
+ "".join(traceback.format_stack(limit=8)))
return alt_f4(self, widget)
def beenden_spion(self):
protokoll.append("beenden()\n" + "".join(traceback.format_stack(limit=10)))
return beenden(self)
monkeypatch.setattr(rahmen.Hauptrahmen, "alt_f4", alt_f4_spion)
monkeypatch.setattr(type(lauf.app), "beenden", beenden_spion)
lauf.ablauf([
(IMMER, lambda: InfoDialog(lauf.root).zeigen(), "Info öffnen"),
# Handler direkt mit dem Dialog als Empfänger aufrufen: Erzeugte Tastendrücke stellt Tk unter
# Windows nur dem Fenster mit Tastaturfokus zu – auf dem Build-Rechner gingen sie verloren.
# Dass Alt+F4 an diesen Handler gebunden ist, prüft test_alt_f4_ist_gebunden.
(lambda: lauf.dialog(InfoDialog) is not None,
lambda: lauf.app.rahmen.alt_f4(lauf.dialog(InfoDialog)), "Alt+F4 im Dialog"),
(lambda: lauf.dialog(InfoDialog) is None, lambda: None, "Dialog geschlossen"),
], sekunden=8)
diagnose = "\n---\n".join(protokoll) + "\nFehler:\n" + "\n".join(lauf.fehler)
assert lauf.lebt, lauf.lebt_sonst("Alt+F4 im Dialog darf nicht das ganze Programm beenden\n" + diagnose)
assert not lauf.uebrig, f"{lauf.uebrig}\n{diagnose}"
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()),
"provisionsarten": lambda a: a.provisionsarten(),
"staffel": lambda a: a.standard_staffel(),
}
@pytest.mark.parametrize("name", ["haendler", "abrechnungen", "export", "einstellungen", "neu", "info", "tipp",
"dateien", "hilfe", "assistent", "prozent", "neu_berechnen", "provisionsarten",
"staffel"])
@pytest.mark.parametrize("design", ["win95", "modern"])
def test_schliessen_waehrend_dialog_offen(home, name, design):
_profil_mit_daten(home)
lauf = Lauf(home, {"assistent_erledigt": True, "tipps_beim_start": False, "design": design})
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_ist_gebunden(lauf_mit_daten):
"""Alt+F4 muss im Win95-Rahmen an den eigenen Handler gebunden sein (für alle Fenster)."""
lauf = lauf_mit_daten
if not lauf.app.rahmen.klassisch:
pytest.skip("nur im Win95-Rahmen")
skript = lauf.root.bind_all("<Alt-F4>")
assert skript, "Alt+F4 ist nicht gebunden"
lauf.pruefen()
def test_alt_f4_bevor_die_sperre_steht(lauf_mit_daten, monkeypatch):
"""Erzwingt den Zustand vom langsamen Build-Rechner: Dialog ist offen, aber Tk-Sperre und
Zeichnen sind noch nicht fertig. Alt+F4 muss trotzdem nur den Dialog schließen."""
from provtool.gui import win95 as w
from provtool.gui.dialogs import InfoDialog
monkeypatch.setattr(w.Dialog, "winfo_ismapped", lambda self: 0) # „noch nicht gezeichnet“
lauf = lauf_mit_daten
lauf.ablauf([
(IMMER, lambda: InfoDialog(lauf.root).zeigen(), "Info öffnen"),
# wie unter Windows: Tk liefert den Tastendruck an das Fenster mit Fokus – hier noch das Hauptfenster
(lambda: lauf.dialog(InfoDialog) is not None, lambda: lauf.app.rahmen.alt_f4(lauf.root),
"Alt+F4 (Fokus noch beim Hauptfenster)"),
(lambda: lauf.dialog(InfoDialog) is None, lambda: None, "Dialog geschlossen"),
], sekunden=8)
assert lauf.lebt, lauf.lebt_sonst("Alt+F4 darf bei offenem Dialog nicht das ganze Programm beenden")
assert not lauf.uebrig, lauf.diagnose()
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: lauf.app.rahmen.alt_f4(rg().blase), "Alt+F4"),
(lambda: rg().blase is None and not rg().rahmen, lambda: None, "Rundgang samt Rahmen weg"),
], sekunden=8)
assert lauf.lebt, lauf.lebt_sonst("Alt+F4 im Rundgang darf nicht das Programm beenden")
assert not lauf.uebrig, lauf.diagnose()
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.diagnose()
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()
# =========================================================================== Windows: Popups bleiben erreichbar
import sys # noqa: E402
nur_windows = pytest.mark.skipif(not sys.platform.startswith("win"),
reason="Win32-Fensterbesitz gibt es nur unter Windows (im Build: Wine)")
@nur_windows
def test_windows_popup_kommt_nach_alt_tab_wieder_nach_vorn(lauf_mit_daten):
"""Gemeldet: Popup offen, woanders hingeklickt – danach kam das Popup nicht mehr nach vorn
(auch nicht per Alt+Tab). Geprüft wird: Popup gehört dem Hauptfenster, das Hauptfenster ist
gesperrt, und holt man das Hauptfenster zurück, liegt das Popup darüber."""
from provtool.gui import win32
from provtool.gui.dialogs import InfoDialog
lauf = lauf_mit_daten
m = {}
def messen():
d = lauf.dialog(InfoDialog)
fremd = tk.Toplevel(lauf.root) # steht für ein anderes Programm
fremd.geometry("400x300+50+50")
fremd.update()
m.update(besitzer=win32.besitzer_von(d), haupt=win32.hwnd(lauf.root),
haupt_bedienbar=win32.ist_bedienbar(lauf.root), taskleiste=win32.hat_taskleisteneintrag(d))
win32.nach_vorn(fremd) # „woanders hingeklickt“
lauf.root.update()
m["woanders"] = win32.stapel(d, lauf.root, fremd)
win32.nach_vorn(lauf.root) # Alt+Tab / Taskleiste zurück zu uns
lauf.root.update()
m["zurueck"] = win32.stapel(d, lauf.root, fremd)
fremd.destroy()
d.abbrechen()
lauf.ablauf([
(IMMER, lambda: InfoDialog(lauf.root).zeigen(), "Info öffnen"),
(lambda: lauf.dialog(InfoDialog) is not None, lambda: lauf.root.after(150, messen), "messen"),
(lambda: "zurueck" in m and lauf.dialog(InfoDialog) is None, lambda: None, "geschlossen"),
], sekunden=10)
assert not lauf.uebrig, lauf.diagnose()
assert m["besitzer"] == m["haupt"], "Popup gehört nicht dem Hauptfenster"
assert m["haupt_bedienbar"] is True, "Hauptfenster soll verschiebbar bleiben (nur der Inhalt ist gesperrt)"
assert m["taskleiste"] is True, "Popup braucht einen eigenen Eintrag in der Taskleiste"
assert m["woanders"][0] == 2, f"Testaufbau: das fremde Fenster sollte oben liegen ({m['woanders']})"
assert m["zurueck"][:2] == [0, 1], f"Popup liegt nach dem Zurückholen nicht über dem Hauptfenster: {m['zurueck']}"
assert win32.ist_bedienbar(lauf.root), "Hauptfenster nach dem Schließen nicht wieder freigegeben"
lauf.pruefen()
def test_verschachtelte_rueckfrage_sperrt_der_reihe_nach(lauf_mit_daten):
"""Händler-Dialog -> Rückfrage: gesperrt sind Hauptfenster-Inhalt und Händler-Dialog (nicht dessen
Titelleiste); nach der Rückfrage ist der Händler-Dialog wieder frei, das Hauptfenster noch nicht."""
from provtool.gui import modal, win32
from provtool.gui import win95 as w
from provtool.gui.dialogs import HaendlerDialog
lauf = lauf_mit_daten
m = {}
rueckfrage = lambda: next((d for d in lauf.dialoge() if d.title() == "Test-Rückfrage"), None) # noqa: E731
gesperrt = lambda wdg: modal.TAG in wdg.bindtags() # noqa: E731
def messen_innen():
h, r = lauf.dialog(HaendlerDialog), rueckfrage()
m.update(h_inhalt=gesperrt(h.liste), h_titel=gesperrt(h.titelleiste) if h.titelleiste else False,
haupt_inhalt=gesperrt(lauf.app.ptree), haupt_titel=gesperrt(lauf.app.rahmen.titelleiste)
if lauf.app.rahmen.titelleiste else False,
r_besitzer=win32.besitzer_von(r), h=win32.hwnd(h))
r.destroy()
def messen_aussen():
h = lauf.dialog(HaendlerDialog)
m.update(h_inhalt_danach=gesperrt(h.liste), haupt_inhalt_danach=gesperrt(lauf.app.ptree))
h.abbrechen()
lauf.ablauf([
(IMMER, lauf.app.haendler, "Händler-Dialog öffnen"),
(lambda: lauf.dialog(HaendlerDialog) is not None,
lambda: w.info(lauf.dialog(HaendlerDialog), "Test-Rückfrage", "?"), "Rückfrage öffnen"),
(lambda: rueckfrage() is not None, lambda: lauf.root.after(100, messen_innen), "innen messen"),
(lambda: "h" in m and rueckfrage() is None, messen_aussen, "außen messen"),
(lambda: lauf.dialog(HaendlerDialog) is None, lambda: None, "alles zu"),
], sekunden=12)
assert not lauf.uebrig, lauf.diagnose()
assert m["h_inhalt"] and m["haupt_inhalt"], "Inhalte müssen gesperrt sein, solange die Rückfrage offen ist"
assert not m["h_titel"] and not m["haupt_titel"], "Titelleisten (verschieben) dürfen nicht gesperrt sein"
assert not m["h_inhalt_danach"] and m["haupt_inhalt_danach"]
assert not gesperrt(lauf.app.ptree), "Hauptfenster nach dem Schließen nicht wieder freigegeben"
if sys.platform.startswith("win"):
assert m["r_besitzer"] == m["h"], "Rückfrage gehört nicht dem Händler-Dialog"
lauf.pruefen()
@nur_windows
def test_windows_rundgang_sperrt_und_gibt_frei(lauf_mit_daten):
from provtool.gui import win32
lauf = lauf_mit_daten
rg = lambda: getattr(lauf.app, "_rundgang", None) # noqa: E731
m = {}
def messen_und_weiter():
m.setdefault("gesperrt", []).append(not win32.ist_bedienbar(lauf.root))
m.setdefault("besitzer", []).append(win32.besitzer_von(rg().blase) == win32.hwnd(lauf.root))
rg()._weiter()
schritte = [(IMMER, lauf.app.rundgang, "Rundgang starten")]
for i in range(3):
schritte.append((lambda i=i: rg() is not None and rg().blase is not None and rg().nr == i,
messen_und_weiter, f"Schritt {i + 1}"))
schritte.append((IMMER, lambda: rg().beenden(), "Rundgang beenden"))
lauf.ablauf(schritte, sekunden=12)
assert not lauf.uebrig, lauf.diagnose()
assert all(m["gesperrt"]), "Hauptfenster während des Rundgangs nicht gesperrt"
assert all(m["besitzer"]), "Sprechblase gehört nicht dem Hauptfenster"
assert win32.ist_bedienbar(lauf.root), "Hauptfenster nach dem Rundgang nicht freigegeben"
lauf.pruefen()
# =========================================================================== Rückmeldungen vom 2.11. (Teil 2)
def test_gedrueckte_knoepfe_behalten_ihre_schrift(lauf_mit_daten):
"""Gemeldet: Im gedrückten Zustand war die Beschriftung weg (activeforeground = Grau)."""
from provtool.gui import win95 as w
lauf = lauf_mit_daten
for b in (w.knopf(lauf.root, "Test"), tk.Button(lauf.root, text="roh")):
assert b.cget("activeforeground") != b.cget("activebackground"), "Schrift gedrückter Knöpfe unsichtbar"
b.destroy()
lauf.pruefen()
def test_tipp_text_bleibt_im_sichtbaren_bereich(lauf_mit_daten):
from provtool.gui.assistent import TIPPS, TippDesTages
lauf = lauf_mit_daten
m = {}
def messen():
d = lauf.dialog(TippDesTages)
rahmen = d.text.master
for t in TIPPS: # jeden Tipp einmal anzeigen
d.text.configure(text=t)
d.update_idletasks()
m.setdefault("breiten", []).append((d.text.winfo_reqwidth(), rahmen.winfo_width()))
m.setdefault("hoehen", []).append((d.text.winfo_reqheight(), rahmen.winfo_height()))
d.abbrechen()
lauf.ablauf([
(IMMER, lauf.app.tipp_des_tages, "Tipp öffnen"),
(lambda: lauf.dialog(TippDesTages) is not None, lambda: lauf.root.after(100, messen), "messen"),
(lambda: "breiten" in m and lauf.dialog(TippDesTages) is None, lambda: None, "zu"),
])
assert not lauf.uebrig, lauf.diagnose()
for text_b, rahmen_b in m["breiten"]:
assert text_b <= rahmen_b, f"Tipp-Text ({text_b}px) breiter als sein Bereich ({rahmen_b}px)"
for text_h, rahmen_h in m["hoehen"]:
assert text_h <= rahmen_h, f"Tipp-Text ({text_h}px) höher als sein Bereich ({rahmen_h}px)"
lauf.pruefen()
def test_strg_ruecktaste_loescht_ganzes_wort(lauf_mit_daten):
from provtool.gui import win95 as w
lauf = lauf_mit_daten
e = tk.Entry(lauf.root)
e.insert(0, "Mobilfunk Schmid GmbH")
ev = type("E", (), {"widget": e})()
e.icursor("end")
w._wort_weg(ev, -1)
assert e.get() == "Mobilfunk Schmid "
w._wort_weg(ev, -1)
assert e.get() == "Mobilfunk "
e.icursor(0)
w._wort_weg(ev, 1)
assert e.get() == ""
# Bindung ist für alle Eingabefelder aktiv
for klasse in ("Entry", "TEntry", "TCombobox"):
assert lauf.root.bind_class(klasse, "<Control-BackSpace>"), klasse
assert lauf.root.bind_class(klasse, "<Control-Delete>"), klasse
e.destroy()
lauf.pruefen()
def test_menue_wechselt_beim_ueberfahren(lauf_mit_daten):
"""Gemeldet: Bei offenem Menü wechselte es nicht mit, wenn man zum nächsten Eintrag fuhr."""
lauf = lauf_mit_daten
leiste = lauf.app.menueleiste
datei, haendler = leiste.knoepfe["Datei"], leiste.knoepfe["Händler"]
m = {}
def ueberfahren():
popup = leiste.menues[datei]
ev = type("E", (), {"x_root": haendler.winfo_rootx() + 3, "y_root": haendler.winfo_rooty() + 3})()
popup._bewegung(ev)
def pruefen_und_esc():
m["offen"] = leiste.offener_knopf
m["popup_offen"] = leiste.menues[haendler].offen
leiste.menues[haendler].fenster.event_generate("<Escape>")
leiste.menues[haendler].schliessen()
lauf.ablauf([
(IMMER, lambda: leiste._klick(datei), "Datei öffnen"),
(lambda: leiste.menues[datei].offen, ueberfahren, "zu „Händler“ fahren"),
(lambda: leiste.menues[haendler].offen, pruefen_und_esc, "prüfen + schließen"),
(lambda: leiste.offener_knopf is None, lambda: None, "alles zu"),
])
assert not lauf.uebrig, lauf.diagnose()
assert m["offen"] is haendler and m["popup_offen"]
assert not leiste.menues[datei].offen
lauf.pruefen()
def test_menue_geht_unter_seinem_menuepunkt_auf(lauf_mit_daten):
"""Unter Windows landete das Menü zunächst oben links in der Bildschirmecke."""
lauf = lauf_mit_daten
leiste = lauf.app.menueleiste
m = {}
for name in ("Datei", "Händler"):
knopf = leiste.knoepfe[name]
def messen(knopf=knopf, name=name):
f = leiste.menues[knopf].fenster
m[name] = (f.winfo_rootx() - knopf.winfo_rootx(),
f.winfo_rooty() - (knopf.winfo_rooty() + knopf.winfo_height()))
leiste.schliessen()
lauf.ablauf([(IMMER, lambda knopf=knopf: leiste._klick(knopf), f"{name} öffnen"),
(lambda knopf=knopf: leiste.menues[knopf].offen, lambda: lauf.root.after(100, messen), "messen"),
(lambda name=name: name in m, lambda: None, "fertig")])
assert not lauf.uebrig, lauf.diagnose()
for name, (dx, dy) in m.items():
assert abs(dx) <= 3 and abs(dy) <= 3, f"Menü „{name}“ nicht unter seinem Menüpunkt (Abstand {dx}, {dy})"
lauf.pruefen()
def test_menue_befehl_wird_ausgefuehrt(lauf_mit_daten):
from provtool.gui.assistent import TippDesTages
lauf = lauf_mit_daten
leiste = lauf.app.menueleiste
hilfe = leiste.knoepfe["?"]
def tipp_waehlen():
popup = leiste.menues[hilfe]
i = next(i for i, (e, _, _) in enumerate(popup.zeilen) if e["label"].startswith("Tipp des Tages"))
popup._ausfuehren(i)
lauf.ablauf([
(IMMER, lambda: leiste._klick(hilfe), "? öffnen"),
(lambda: leiste.menues[hilfe].offen, tipp_waehlen, "Tipp des Tages wählen"),
(lambda: lauf.dialog(TippDesTages) is not None, lambda: lauf.dialog(TippDesTages).abbrechen(), "zu"),
])
assert not lauf.uebrig, lauf.diagnose()
assert not leiste.menues[hilfe].offen
lauf.pruefen()
def test_hauptfenster_bei_offenem_dialog_verschiebbar_inhalt_gesperrt(lauf_mit_daten, monkeypatch):
"""Gemeldet: Bei offenem Dialog ließ sich das Hauptfenster nicht verschieben."""
from provtool.gui import modal, rahmen
from provtool.gui.dialogs import InfoDialog
monkeypatch.setattr(rahmen, "NATIV_VERSCHIEBEN", False) # Tk-Weg prüfen (nativ macht Windows selbst)
lauf = lauf_mit_daten
app = lauf.app
if not app.rahmen.klassisch:
pytest.skip("nur im Win95-Rahmen")
m = {"neu_aufgerufen": False}
monkeypatch.setattr(type(app), "neu", lambda self: m.__setitem__("neu_aufgerufen", True))
titel = app.rahmen.titelleiste.titel_lbl
def pruefen():
x0 = lauf.root.winfo_x()
rx, ry = titel.winfo_rootx() + 30, titel.winfo_rooty() + 5
titel.event_generate("<ButtonPress-1>", x=30, y=5, rootx=rx, rooty=ry)
titel.event_generate("<B1-Motion>", x=70, y=5, rootx=rx + 40, rooty=ry)
titel.event_generate("<ButtonRelease-1>", x=70, y=5, rootx=rx + 40, rooty=ry)
lauf.root.update()
m["verschoben"] = lauf.root.winfo_x() - x0
knopf = app.tb["Neu"] # Klick auf den gesperrten Inhalt
knopf.event_generate("<ButtonPress-1>", x=5, y=5)
knopf.event_generate("<ButtonRelease-1>", x=5, y=5)
m["gesperrt"] = modal.TAG in app.ptree.bindtags()
lauf.dialog(InfoDialog).abbrechen()
lauf.ablauf([
(IMMER, lambda: InfoDialog(lauf.root).zeigen(), "Dialog öffnen"),
(lambda: lauf.dialog(InfoDialog) is not None, lambda: lauf.root.after(100, pruefen), "prüfen"),
(lambda: "verschoben" in m and lauf.dialog(InfoDialog) is None, lambda: None, "zu"),
])
assert not lauf.uebrig, f"{lauf.uebrig} {m}\n" + "\n".join(lauf.fehler)
assert m["verschoben"] == 40, f"Hauptfenster ließ sich nicht verschieben ({m['verschoben']} px)"
assert m["gesperrt"] and not m["neu_aufgerufen"], "Inhalt des Hauptfensters war nicht gesperrt"
assert modal.TAG not in app.ptree.bindtags(), "Sperre nach dem Schließen nicht aufgehoben"
lauf.pruefen()
def test_rand_zeiger_wird_zurueckgesetzt(lauf_mit_daten):
"""Gemeldet: Der Größenänderungs-Zeiger blieb manchmal stehen."""
lauf = lauf_mit_daten
r = lauf.app.rahmen
if not r.klassisch:
pytest.skip("nur im Win95-Rahmen")
lauf.ablauf([]) # erst wenn das Hauptfenster zu sehen ist (Größen stimmen dann)
ev = type("E", (), {"x": 2, "y": lauf.root.winfo_height() // 2})()
r._rand_cursor(ev)
# Den Zeiger verwaltet immer Tk – auch mit nativem Windows-Rahmen (gemeldet in 1.3.0: setzte
# Windows ihn am Rand, blieb er im Fenster der Größenpfeil, weil Tk davon nichts wusste).
assert r.aussen.cget("cursor"), "am Rand muss der Größenänderungs-Zeiger erscheinen"
r.inhalt.event_generate("<Enter>")
lauf.root.update()
assert r.aussen.cget("cursor") == "", "Zeiger wurde beim Betreten des Inhalts nicht zurückgesetzt"
# Ecken: in der Nähe des Ecks diagonal
assert r._welcher_rand(2, 10) == "nw" and r._welcher_rand(r.aussen.winfo_width() - 3, 10) == "ne"
lauf.pruefen()
def test_eigene_kennungen_ausblenden_in_der_oberflaeche(lauf_mit_daten):
from provtool.db import EIGEN
lauf = lauf_mit_daten
app = lauf.app
alle_vorher = int(app.htree.item("-1", "values")[0])
app.db.eigene_setzen("freenet", "999")
app._aktualisieren()
assert app.htree.exists(str(EIGEN)), "Eintrag „(Eigene – ausgeblendet)“ fehlt"
assert int(app.htree.item("-1", "values")[0]) == alle_vorher - 1, "eigene Positionen zählen noch mit"
assert all(p["haendler_id"] != EIGEN for p in app._gefiltert())
lauf.pruefen()
@nur_windows
def test_windows_minimieren_mit_offenem_dialog(lauf_mit_daten):
"""Minimieren bei offenem Dialog darf den Dialog nicht zerstören; nach dem Wiederherstellen ist er da."""
from provtool.gui import win32
from provtool.gui.dialogs import InfoDialog
lauf = lauf_mit_daten
m = {}
def minimieren():
lauf.app.rahmen.minimieren()
lauf.root.update()
m["minimiert"] = win32.ist_minimiert(lauf.root)
win32.user32().ShowWindow(win32.hwnd(lauf.root), 9) # SW_RESTORE (wie Klick in der Taskleiste)
lauf.root.update()
m["dialog_da"] = lauf.dialog(InfoDialog) is not None
lauf.dialog(InfoDialog).abbrechen()
lauf.ablauf([
(IMMER, lambda: InfoDialog(lauf.root).zeigen(), "Dialog öffnen"),
(lambda: lauf.dialog(InfoDialog) is not None, lambda: lauf.root.after(150, minimieren), "minimieren"),
(lambda: "dialog_da" in m, lambda: None, "fertig"),
], sekunden=12)
assert not lauf.uebrig, lauf.diagnose()
assert m["minimiert"], "Hauptfenster wurde nicht minimiert"
assert m["dialog_da"], "Dialog nach dem Minimieren verschwunden"
lauf.pruefen()
@nur_windows
def test_windows_echtes_fenster_andocken_und_rand(lauf_mit_daten):
"""Gemeldet: Win+Hoch/Runter und Andocken am Bildschirmrand gingen nicht, Größe ziehen war zäh.
Das Hauptfenster muss für Windows ein echtes Programmfenster sein (Größenrahmen, Min/Max), dessen
Windows-Rahmen unsichtbar ist; am Rand meldet es Windows den Größenrahmen."""
from provtool.gui import nativ, rahmen
lauf = lauf_mit_daten
r = lauf.app.rahmen
if not r.klassisch:
pytest.skip("nur im Win95-Rahmen")
m = {}
def pruefen():
n = r.nativ
m["aktiv"] = r.nativ_an
if not r.nativ_an:
return
stil, ex = n.stil()
m["stil"] = {name: bool(stil & bit) for name, bit in (
("Größenrahmen", nativ.WS_THICKFRAME), ("Maximieren", nativ.WS_MAXIMIZEBOX),
("Minimieren", nativ.WS_MINIMIZEBOX), ("Systemmenü", nativ.WS_SYSMENU), ("Titel", nativ.WS_CAPTION))}
m["programmfenster"] = not (ex & nativ.WS_EX_TOOLWINDOW) and bool(ex & nativ.WS_EX_APPWINDOW)
m["rahmen_unsichtbar"] = n.innen_gleich_aussen()
x, y, bw, bh = n.aussenmass()
m["tk_gleich_aussen"] = (lauf.root.winfo_width(), lauf.root.winfo_height()) == (bw, bh)
my = y + bh // 2
m["links"] = n.trefferpruefung(n.hwnd, x + 2, my) == nativ.HT["w"]
m["ecke"] = n.trefferpruefung(n.hwnd, x + bw - 3, y + bh - 3) == nativ.HT["se"]
m["ecke_lang"] = n.trefferpruefung(n.hwnd, x + bw - 3, y + 10) == nativ.HT["ne"]
m["innen"] = n.trefferpruefung(n.hwnd, x + bw // 2, y + bh // 2) == nativ.HTCLIENT
# Tk darf das Fenster bei internen Größenanfragen nicht um einen Rahmen vergrößern
n.setzen(x, y, bw - 37, bh - 23) # wie Größe ziehen durch den Benutzer
lauf.root.update()
vorher = n.aussenmass()
lauf.app.status.set("info", " " + "sehr langer Statustext " * 20)
lauf.root.update_idletasks()
lauf.root.update()
m["waechst_nicht"] = (n.aussenmass(), vorher)
# Maximieren über Windows (wie Win+Hoch / an den oberen Rand ziehen) und zurück
n.befehl(nativ.SC_MAXIMIZE)
lauf.root.update()
ax, ay, aw, ah = rahmen.arbeitsbereich(lauf.root)
m["max"] = (r.maximiert, n.maximiert())
m["max_inhalt"] = ((lauf.root.winfo_rootx(), lauf.root.winfo_rooty(), lauf.root.winfo_width(),
lauf.root.winfo_height()), (ax, ay, aw, ah))
m["max_symbol"] = str(r.titelleiste.knoepfe["max"].cget("image")) == str(r.titelleiste._bilder["restore"])
m["kein_rand_maximiert"] = n.randbereich(ax + 1, ay + ah // 2) == ""
n.befehl(nativ.SC_RESTORE)
lauf.root.update()
m["zurueck"] = (r.maximiert, n.aussenmass() == vorher)
m["normale_geometrie"] = r.normale_geometrie()
lauf.ablauf([(IMMER, lambda: lauf.root.after(300, pruefen), "prüfen"),
(lambda: "aktiv" in m and ("zurueck" in m or not m["aktiv"]), lambda: None, "fertig")],
sekunden=15)
assert not lauf.uebrig, lauf.diagnose()
assert m["aktiv"], "natives Fenster wurde nicht eingerichtet"
assert all(m["stil"].values()), f"Fensterstil unvollständig: {m['stil']}"
assert m["programmfenster"], "Fenster ist für Windows noch ein Werkzeugfenster (kein Andocken/Win+Pfeil)"
assert m["rahmen_unsichtbar"] and m["tk_gleich_aussen"], "Windows-Rahmen ist sichtbar / Tk-Größe passt nicht"
for k in ("links", "ecke", "ecke_lang", "innen"):
assert m[k], f"Trefferprüfung am Rand falsch: {k} ({m})"
assert m["waechst_nicht"][0] == m["waechst_nicht"][1], f"Fenster ist gewachsen: {m['waechst_nicht']}"
assert m["max"] == (True, True), f"Maximieren über Windows nicht erkannt: {m['max']}"
assert m["max_inhalt"][0] == m["max_inhalt"][1], f"maximiert nicht genau im Arbeitsbereich: {m['max_inhalt']}"
assert m["max_symbol"] and m["kein_rand_maximiert"]
assert m["zurueck"] == (False, True), f"Wiederherstellen: {m['zurueck']}"
assert m["normale_geometrie"].startswith(f"{m['waechst_nicht'][1][2]}x{m['waechst_nicht'][1][3]}+")
lauf.pruefen()
def test_haendler_dialog_airtime_staffel_grossbuchstaben(lauf_mit_daten):
"""Airtime-% und eigene Jahresbonus-Staffel je Händler; die GROSSBUCHSTABEN-Regel ist ein Häkchen
(gemeldet: Vodafone fehlte im Export – die Regel ging verloren, wenn man „Hinzufügen“ vergaß)."""
from provtool.db import CAPS_ANBIETER, CAPS_WERT
from provtool.gui.dialogs import HaendlerDialog, StaffelDialog
lauf = lauf_mit_daten
app = lauf.app
hid = app.db.haendler_liste()[0].id
hd = lambda: lauf.dialog(HaendlerDialog) # noqa: E731
sd = lambda: lauf.dialog(StaffelDialog) # noqa: E731
def eintragen():
d = hd()
d.v_air.set("40")
d.v_caps.set(True)
d.v_eigen_staffel.set(True)
d._staffel_zeigen()
lauf.root.after(10, d._staffel_bearbeiten)
def staffel_eingeben():
d = sd()
d._setzen([(1, 100), (3, None)])
d.zeilen[0][2].set("2,50")
d._ok()
lauf.ablauf([
(IMMER, lambda: app.haendler(hid), "Händler-Dialog"),
(lambda: hd() is not None, eintragen, "Airtime, Häkchen, eigene Staffel"),
(lambda: sd() is not None, staffel_eingeben, "Staffel eingeben"),
(lambda: sd() is None and "2,50" in hd().l_staffel.cget("text"), lambda: hd()._speichern(), "Speichern"),
(IMMER, lambda: hd().abbrechen(), "Schließen"),
(lambda: hd() is None, lambda: None, "zu"),
])
assert not lauf.uebrig, lauf.diagnose()
h = app.db.haendler(hid)
assert h.airtime_prozent == 40
assert h.staffel == [(1, 250), (3, None)]
assert (CAPS_ANBIETER, CAPS_WERT) in app.db.kennungen(hid)
assert ("freenet", "111") in app.db.kennungen(hid)
lauf.pruefen()
def test_export_dialog_standard_eine_datei_ohne_null(lauf_mit_daten, tmp_path):
from provtool.gui.dialogs import ExportDialog
lauf = lauf_mit_daten
app = lauf.app
app.db.betrag_setzen(next(p["id"] for p in app.positionen if p["provisionsart"] == "SIM-Karten"
and p["haendler_id"] > 0), 0)
app._aktualisieren()
app.settings["export_ordner"] = str(tmp_path / "x")
app.settings["export_pro_anbieter"] = True # alte Einstellung darf nicht mehr trennen
ergebnis = {}
def ok():
d = lauf.dialog(ExportDialog)
ergebnis.update(anb=d.v_anb.get(), null=d.v_null.get(), haendler=list(d.haken))
d.v_auf.set(False)
d._ok()
from provtool.gui import win95 as w
lauf.ablauf([
(IMMER, app.exportieren, "Export"),
(lambda: lauf.dialog(ExportDialog) is not None, ok, "Exportieren"),
(lambda: any(d.title() == "Export" for d in lauf.dialoge()),
lambda: [d.abbrechen() for d in lauf.dialoge() if isinstance(d, w.Dialog)], "Meldung schließen"),
])
assert not lauf.uebrig, lauf.diagnose()
assert ergebnis == {"anb": False, "null": True, "haendler": [app.db.haendler_liste()[0].id]}
import openpyxl
dateien = sorted((tmp_path / "x").glob("*.xlsx"))
assert [d.name for d in dateien] == ["Händler A - Oktober 2026.xlsx"]
zeilen = [r for r in openpyxl.load_workbook(dateien[0]).active.iter_rows(values_only=True) if r[1]]
assert [r[1] for r in zeilen] == ["Sonderbonus Aktion"], "0-€-Position wurde exportiert"
lauf.pruefen()
def test_rand_druecken_startet_natives_ziehen(lauf_mit_daten, monkeypatch):
"""Mit nativem Rahmen zieht Windows die Größe – angestoßen beim Drücken auf den Rand."""
lauf = lauf_mit_daten
r = lauf.app.rahmen
if not r.nativ_an:
pytest.skip("nur mit nativem Windows-Rahmen")
lauf.ablauf([])
gezogen = []
monkeypatch.setattr(r.nativ, "groesse_ziehen", gezogen.append)
ev = type("E", (), {"x": 2, "y": lauf.root.winfo_height() // 2, "x_root": 0, "y_root": 0})()
assert r._resize_start(ev) == "break"
ev.x, ev.y = r.aussen.winfo_width() - 2, r.aussen.winfo_height() - 2
r._resize_start(ev)
assert gezogen == ["w", "se"]
lauf.pruefen()
def test_modern_ist_standard(lauf_modern):
from provtool.gui import win95 as w
lauf = lauf_modern
assert w.DESIGN == "modern" and not lauf.app.rahmen.klassisch and lauf.app.rahmen.titelleiste is None
lauf.ablauf([(IMMER, lambda: lauf.app.menueleiste._klick(lauf.app.menueleiste.knoepfe["Datei"]), "Menü"),
(lambda: lauf.app.menueleiste.offener_knopf is not None,
lambda: lauf.app.menueleiste.schliessen(), "zu")])
assert not lauf.uebrig, lauf.diagnose()
lauf.pruefen()
def test_export_zeitraum_kennzeichnen_und_erledigt(lauf_modern, tmp_path, monkeypatch):
"""Zeitraum abrechnen, exportierte Positionen nicht erneut exportieren, Abrechnung erledigt sperrt."""
from provtool.gui import win95 as w
from provtool.gui.dialogs import ExportDialog
lauf = lauf_modern
app = lauf.app
app.settings["export_ordner"] = str(tmp_path / "x")
meldungen = []
monkeypatch.setattr(w, "info", lambda m, t, text: meldungen.append(text))
monkeypatch.setattr(w, "frage", lambda *a, **k: True)
def ok(von="", bis=""):
d = lauf.dialog(ExportDialog)
d.v_von.set(von)
d.v_bis.set(bis)
d.v_auf.set(False)
d._ok()
def ok_offen():
d = lauf.dialog(ExportDialog)
d.v_von.set("01.10.2026")
d.v_bis.set("02.10.2026")
next(k for k in _alle(d) if isinstance(k, tk.Button) and k.cget("text") == "Noch nicht abgerechnet").invoke()
ok(d.v_von.get(), d.v_bis.get())
lauf.ablauf([
(IMMER, app.exportieren, "Export Oktober 6."),
(lambda: lauf.dialog(ExportDialog) is not None, lambda: ok("01.10.2026", "05.10.2026"), "nur bis 5.10."),
(lambda: lauf.dialog(ExportDialog) is None, app.exportieren, "Export Rest"),
(lambda: lauf.dialog(ExportDialog) is not None, ok_offen, "Noch nicht abgerechnet"),
(lambda: lauf.dialog(ExportDialog) is None, app.exportieren, "nochmal"),
])
assert not lauf.uebrig, lauf.diagnose()
assert sum(1 for p in app.positionen if p["abgerechnet"]) == 2
assert "1 Position(en) als abgerechnet" in meldungen[0] and "1 Position(en) als abgerechnet" in meldungen[1]
assert "bereits abgerechnet" in meldungen[2], meldungen
# erledigt: Änderungen gesperrt
app.erledigt_umschalten()
assert app.db.erledigt(app.aid)
app.ptree.selection_set(app.ptree.get_children()[0])
app.prozent()
assert "erledigt" in meldungen[-1]
lauf.pruefen()
def _alle(widget):
for kind in widget.winfo_children():
yield kind
yield from _alle(kind)
def test_abrechnungen_links_und_import_fragt_nach_ziel(lauf_modern, home, monkeypatch):
from provtool.gui import win95 as w
from provtool.gui.dialogs import AbrechnungenDialog, ImportBericht
monkeypatch.setattr(w, "frage", lambda *a, **k: True) # „bereits importiert – trotzdem?“ (Testdatei ist schon drin)
lauf = lauf_modern
app = lauf.app
alt = app.aid
neu = app.db.abrechnung_neu("November 2026")
app._aktualisieren()
assert set(app.atree.get_children()) == {str(alt), str(neu)}, "Abrechnungen fehlen links"
app.atree.selection_set(str(neu))
lauf.root.update()
assert app.aid == neu and not app.positionen, "Klick links öffnet die Abrechnung nicht"
app.atree.selection_set(str(alt))
lauf.root.update()
def ziel_waehlen():
d = lauf.dialog(AbrechnungenDialog)
d.tree.selection_set(str(neu))
d._oeffnen()
lauf.ablauf([
(IMMER, lambda: app.importieren([str(home / "freenet.csv")]), "Import"),
(lambda: lauf.dialog(AbrechnungenDialog) is not None, ziel_waehlen, "Ziel: November"),
(lambda: lauf.dialog(ImportBericht) is not None, lambda: lauf.dialog(ImportBericht).abbrechen(), "Bericht zu"),
])
assert not lauf.uebrig, lauf.diagnose()
assert app.aid == neu and len(app.positionen) == 3, "Import nicht in der gewählten Abrechnung"
assert app.atree.selection() == (str(neu),)
lauf.pruefen()
def test_filter_kennzahlen_und_hell_dunkel(lauf_modern):
"""Filter-Knöpfe über der Tabelle, Kennzahl-Kacheln und der Hell/Dunkel-Schalter in der Kopfzeile."""
from provtool import config
from provtool.db import EIGEN
lauf = lauf_modern
app = lauf.app
alle = [p for p in app.positionen if p["haendler_id"] != EIGEN]
assert app.kpi["pos"][0].cget("text") == str(len(alle))
p = alle[0]
app.db.betrag_setzen(p["id"], 123)
app._aktualisieren()
app.pills["manuell"].event_generate("<Button-1>")
lauf.root.update()
manuell = [x for x in app.positionen if x["manuell"] and x["haendler_id"] != EIGEN]
assert len(manuell) == 1
zeilen = [i for g in app.ptree.get_children() for i in app.ptree.get_children(g)]
assert len(zeilen) == len(manuell), "Filter „Von Hand geändert“ wirkt nicht"
assert app.kpi["pos"][0].cget("text") == str(len(manuell))
assert app.pills["manuell"].aktiv and not app.pills["alle"].aktiv
app._filter_setzen("alle")
assert len([i for g in app.ptree.get_children() for i in app.ptree.get_children(g)]) == len(alle)
app.design_umschalten() # schließt das Fenster; main() baut es danach dunkel neu auf
for _ in range(50):
if app._beendet:
break
lauf.root.update()
assert app.neustart and app._beendet
assert config.load()["design"] == "dunkel"