"""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 # Tests starten im normalen Fenster (die App sonst maximiert) einstellungen = {"vollbild": False} | (einstellungen or {}) (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("") 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("") 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("") 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, ""), klasse assert lauf.root.bind_class(klasse, ""), 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("") 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("", x=30, y=5, rootx=rx, rooty=ry) titel.event_generate("", x=70, y=5, rootx=rx + 40, rooty=ry) titel.event_generate("", 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("", x=5, y=5) knopf.event_generate("", 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("") 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] # ... auch der Export (würde sonst offene Positionen abrechnen) n = len(meldungen) app.exportieren() assert lauf.dialog(ExportDialog) is None and len(meldungen) == n + 1 and "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"]._ausloesen() # wie ein Klick (event_generate kommt unter Wine nicht an) 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" def test_startet_maximiert(home): _profil_mit_daten(home) lauf = Lauf(home, {"assistent_erledigt": True, "tipps_beim_start": False, "vollbild": True, "design": "win95"}) try: for _ in range(30): lauf.root.update() time.sleep(0.02) assert lauf.app.rahmen.maximiert, "Programm startet nicht maximiert" finally: lauf.ende() def test_suche_und_filter_blenden_nur_aus(lauf_modern): """Suche/Filter bauen die Tabelle nicht neu auf: Zeilen werden aus- und wieder eingehängt.""" lauf = lauf_modern app = lauf.app alle = [i for g in app.ptree.get_children() for i in app.ptree.get_children(g)] assert len(alle) == 3 app.v_suche.set("Beispiel") app._suche_ausfuehren() sichtbar = [i for g in app.ptree.get_children() for i in app.ptree.get_children(g)] assert len(sichtbar) == 1 and app.ptree.item(sichtbar[0], "text") == "Beispiel KG" assert all(app.ptree.exists(i) for i in alle), "ausgeblendete Zeilen bleiben erhalten (nur ausgehängt)" assert app.kpi["pos"][0].cget("text") == "1" gruppe = app.ptree.parent(sichtbar[0]) assert "(1)" in app.ptree.item(gruppe, "text") and app.ptree.item(gruppe, "open") app.ptree.selection_set(gruppe) assert app._ausgewaehlte_pids() == [int(sichtbar[0][1:])], "Gruppenauswahl = nur sichtbare Zeilen" app.v_suche.set("") app._suche_ausfuehren() assert [i for g in app.ptree.get_children() for i in app.ptree.get_children(g)] == alle assert app.kpi["pos"][0].cget("text") == "3" lauf.pruefen() def test_start_mit_unerreichbarer_datenbank_fragt(home, monkeypatch): """Eingestellter (Netz-)Pfad fehlt: Rückfrage statt Absturz – und es wird dort keine leere DB angelegt.""" from provtool import config from provtool.gui import win95 as w _profil_mit_daten(home) weg = home / "netz" / "provisionen.db" fragen = [] monkeypatch.setattr(w, "_meldung", lambda m, t, text, icon, knoepfe, default=0: fragen.append(text) or "standard") lauf = Lauf(home, {"assistent_erledigt": True, "tipps_beim_start": False, "db_pfad": str(weg)}) try: assert len(fragen) == 1 and str(weg) in fragen[0] assert not weg.exists() and lauf.app.db.path == config.app_dir() / "provisionen.db" assert config.load()["db_pfad"] is None, "Wahl „Standard“ wird gespeichert" assert len(lauf.app.positionen) == 3 finally: lauf.ende() lauf.pruefen() # „Beenden“ gewählt: Programm endet sauber monkeypatch.setattr(w, "_meldung", lambda *a, **k: "ende") (home / "einstellungen.json").write_text(json.dumps({"db_pfad": str(weg), "vollbild": False}), encoding="utf-8") from provtool.gui import app as A root = tk.Tk() with pytest.raises(SystemExit): A.App(root) assert not weg.exists()