- Verschieben/Größe ziehen per PostMessage statt SendMessage: Die Windows-Verschiebeschleife lief innerhalb eines Tk-Ereignisses; Tk führte darin Python-Ereignisse (<Configure>) aus -> harter Absturz „PyEval_RestoreThread … thread state is NULL“ (normal und aus maximiert). - Größen-Mauszeiger am Rand wieder von Tk (blieb sonst im Fenster stehen); Windows zieht beim Drücken. - Neuer Maustest im Build (xdotool, echte Maus): Titelleiste/Rand ziehen, maximieren, herausziehen. - Export: Bei Vodafone steht in Spalte A das Aktivierungsdatum (statt Abrechnungslauf). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1055 lines
47 KiB
Python
1055 lines
47 KiB
Python
"""GUI-Tests: bedienen die echte Oberfläche (unter Xvfb bzw. Wine+Xvfb im Build).
|
||
|
||
Jede Ausnahme in einem Tk-Callback, jeder Absturz des Fehler-Handlers und jeder Eintrag in
|
||
fehler.log lässt den Test fehlschlagen. Modale Dialoge blockieren eine eigene Ereignisschleife –
|
||
deshalb laufen die Testschritte als Tk-Callbacks (Schrittplan) und ein Watchdog bricht hängende
|
||
Tests nach einer Zeitgrenze ab, statt den Build zu blockieren.
|
||
|
||
Ohne Bildschirm werden die Tests übersprungen – außer PROVTOOL_GUI_TESTS=1 ist gesetzt (Build).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import time
|
||
import tkinter as tk
|
||
import traceback
|
||
|
||
import pytest
|
||
|
||
from provtool import config
|
||
from provtool.db import Datenbank, Haendler
|
||
|
||
PFLICHT = os.environ.get("PROVTOOL_GUI_TESTS") == "1"
|
||
IMMER = lambda: True # noqa: E731
|
||
|
||
|
||
class Lauf:
|
||
"""Startet die App in einem eigenen Profil, führt Schrittpläne aus und sammelt alle Fehler."""
|
||
|
||
def __init__(self, home, einstellungen: dict | None = None):
|
||
self.home = home
|
||
if einstellungen is not None:
|
||
(home / "einstellungen.json").write_text(json.dumps(einstellungen), encoding="utf-8")
|
||
self.root = None
|
||
for versuch in range(3):
|
||
try:
|
||
self.root = tk.Tk()
|
||
break
|
||
except tk.TclError as exc:
|
||
# Unter Wine scheitert das Anlegen vieler Tk-Instanzen in einem Prozess gelegentlich
|
||
# mit „couldn't read file init.tcl: Success“ – das betrifft nur die Tests (die App
|
||
# legt genau eine Instanz an). Daher kurz warten und erneut versuchen.
|
||
if "init.tcl" in str(exc) and versuch < 2:
|
||
time.sleep(0.5)
|
||
continue
|
||
if PFLICHT:
|
||
raise
|
||
pytest.skip(f"kein Bildschirm: {exc}")
|
||
self.fehler: list[str] = []
|
||
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
|
||
|
||
|
||
@pytest.fixture
|
||
def lauf_mit_daten(home):
|
||
"""App mit einem Händler und einer Abrechnung (erfundene Daten)."""
|
||
from tests.test_kern import FREENET_KOPF, freenet_zeile
|
||
csv = home / "freenet.csv"
|
||
csv.write_bytes(("\r\n".join([
|
||
FREENET_KOPF,
|
||
freenet_zeile("SIM-Karten", "111", "05.10.2026", "1601111111", "Muster GmbH", "Allnet", "8"),
|
||
freenet_zeile("Sonderbonus Aktion", "111", "06.10.2026", "1602222222", "Beispiel KG", "Classic", "170"),
|
||
freenet_zeile("SIM-Karten", "999", "07.10.2026", "1603333333", "Eigener Kunde", "Flex", "8"),
|
||
]) + "\r\n").encode("cp1252"))
|
||
db = Datenbank(config.db_path())
|
||
db.haendler_speichern(Haendler(0, "Händler A"), [("freenet", "111")])
|
||
from provtool.importer import lies_datei
|
||
db.importieren(db.abrechnung_neu("Oktober 2026"), lies_datei(csv))
|
||
db.close()
|
||
lauf = Lauf(home, {"assistent_erledigt": True, "tipps_beim_start": False})
|
||
yield lauf
|
||
lauf.ende()
|
||
|
||
|
||
def _assistent_offen(lauf):
|
||
from provtool.gui.assistent import Einrichtungsassistent
|
||
return lambda: lauf.dialog(Einrichtungsassistent) is not None
|
||
|
||
|
||
# =========================================================================== Schließen bei offenem Dialog
|
||
|
||
@pytest.mark.parametrize("weg", ["fenster", "beenden"])
|
||
def test_schliessen_waehrend_assistent(home, weg):
|
||
"""Reproduziert den gemeldeten Absturz: Programm schließen, während der Assistent offen ist."""
|
||
lauf = Lauf(home) # frisches Profil → Assistent startet automatisch
|
||
zu = {"fenster": lauf.schliessen_ueber_fenster, "beenden": lauf.app.beenden}[weg]
|
||
lauf.ablauf([(_assistent_offen(lauf), lambda: None, "Assistent erscheint"),
|
||
(IMMER, zu, f"schließen über {weg}")])
|
||
assert not lauf.lebt, "Programm wurde nicht beendet"
|
||
lauf.pruefen()
|
||
|
||
|
||
def test_alt_f4_im_assistent_fragt_und_schliesst_nur_assistent(home):
|
||
"""Alt+F4 bei offenem Assistenten = „Abbrechen“ mit Rückfrage; das Programm bleibt offen."""
|
||
from provtool.gui.assistent import Einrichtungsassistent
|
||
lauf = Lauf(home)
|
||
rueckfrage = lambda: next((d for d in lauf.dialoge() if d.title() == "Einrichtung abbrechen"), None) # noqa: E731
|
||
|
||
def ja_klicken():
|
||
d = rueckfrage()
|
||
d.result = True
|
||
d.destroy()
|
||
|
||
lauf.ablauf([
|
||
(_assistent_offen(lauf), lambda: lauf.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"])
|
||
def test_schliessen_waehrend_dialog_offen(lauf_mit_daten, name):
|
||
lauf = lauf_mit_daten
|
||
oeffnen = _dialog_oeffner()[name]
|
||
lauf.ablauf([(IMMER, lambda: oeffnen(lauf.app), f"{name} öffnen"),
|
||
(lambda: bool(lauf.dialoge()), lauf.schliessen_ueber_fenster, "Programm schließen")])
|
||
assert not lauf.lebt, f"Programm wurde nicht beendet (offen: {lauf.uebrig})"
|
||
lauf.pruefen()
|
||
|
||
|
||
def test_schliessen_waehrend_rueckfrage_im_assistent(home):
|
||
"""Assistent offen → „Abbrechen“ → Rückfrage offen → Programm schließen."""
|
||
from provtool.gui.assistent import Einrichtungsassistent
|
||
lauf = Lauf(home)
|
||
lauf.ablauf([
|
||
(_assistent_offen(lauf), lambda: lauf.dialog(Einrichtungsassistent).abbrechen(), "Abbrechen klicken"),
|
||
(lambda: len(lauf.dialoge()) >= 2, lauf.schliessen_ueber_fenster, "Programm schließen"),
|
||
])
|
||
assert not lauf.lebt
|
||
lauf.pruefen()
|
||
|
||
|
||
def test_schliessen_waehrend_rundgang(lauf_mit_daten):
|
||
lauf = lauf_mit_daten
|
||
lauf.ablauf([
|
||
(IMMER, lauf.app.rundgang, "Rundgang starten"),
|
||
(lambda: getattr(lauf.app, "_rundgang", None) and lauf.app._rundgang.blase, lauf.schliessen_ueber_fenster,
|
||
"Programm schließen"),
|
||
])
|
||
assert not lauf.lebt
|
||
lauf.pruefen()
|
||
|
||
|
||
def test_alt_f4_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()
|