5 Commits
Author SHA1 Message Date
adminandClaude Opus 5.5 2b2d5e8580 Tests: Alt+F4 unabhängig von der Tastaturzustellung des Build-Rechners prüfen
Auf dem Runner gingen erzeugte Tastendrücke verloren (Tk stellt sie unter Windows nur dem Fenster
mit Tastaturfokus zu); der Watchdog brach dann ab und es sah aus, als hätte sich das Programm
beendet. Handler wird jetzt direkt aufgerufen, die Bindung separat geprüft; Watchdog-Abbrüche
werden als Zeitüberschreitung gemeldet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 11:43:29 +01:00
adminandClaude Opus 5.5 86192c853d Test: Diagnose für Alt+F4 im Dialog (wer löst Alt+F4/Beenden aus)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 11:39:11 +01:00
adminandClaude Opus 5.5 bd45ffea17 Alt+F4 bei gerade geöffnetem Dialog schließt nicht mehr das Programm
Auf dem langsameren Build-Runner schlug der Alt+F4-Test fehl: Kam der Tastendruck an, bevor der
Dialog gezeichnet und gesperrt war, übersah die Ausweichsuche das Popup (winfo_ismapped = 0) und
Alt+F4 galt dem Hauptfenster. Die Suche nutzt jetzt den Fensterstatus. Neuer Test erzwingt genau
diesen Zustand plattformunabhängig.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 11:33:54 +01:00
adminandClaude Opus 5.5 26ac90bb0f Popups kommen nach Klick woanders / Alt+Tab wieder nach vorn; Büroklammer im Tipp des Tages
Fehler: War ein Popup offen und man klickte in ein anderes Programm, ließ sich das Popup über
Hauptfenster, Taskleiste oder Alt+Tab nicht mehr nach vorn holen.
Ursache: Rahmenlose Tk-Fenster mit transient() legt Tk unter Windows als Kindfenster des Desktops
(WS_CHILD) an – ohne Besitzer, daher kam beim Zurückholen nur das (gesperrte) Hauptfenster nach vorn.

- Popups (Dialoge, Rückfragen, Rundgang) ohne transient(), stattdessen Windows-Besitzer +
  Sperre des aufrufenden Fensters wie bei echten modalen Windows-Dialogen (gui/win32.py)
- Rundgang: neue Sprechblase erscheint vor dem Entfernen der alten (sonst springt Windows weg)
- Alt+F4 und Modalsperre robust gegen Timing (Sperre wird bei Bedarf nachgeholt)
- Tests (nur unter Windows/Wine): Besitzer, Sperre, Stapelreihenfolge nach „woanders klicken“
  und Zurückholen, verschachtelte Rückfragen, Rundgang
- Tipp des Tages mit freigestelltem Büroklammer-Bild (build/make_tipp_bild.py), in der .exe
  enthalten und vom Selbsttest geprüft

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 11:26:17 +01:00
adminandClaude Opus 5.5 aae28fa7f2 Absturz beim Schließen mit offenem Dialog behoben; GUI-Tests und Selbsttest der .exe im Build
Fehler: Programm schließen, während der Einrichtungs-Assistent offen war → Zugriff auf
geschlossene Datenbank, danach Absturz des Fehler-Handlers.

- beenden(): schließt zuerst offene Dialoge/Rückfragen/Rundgang, lässt wartende Abläufe zu Ende
  laufen und schließt erst dann Datenbank und Fenster; mehrfacher Aufruf unschädlich
- Fehler-Handler protokolliert immer, zeigt aber keine Meldung mehr ohne Fenster
- Alt+F4 schließt nur das aktive bzw. modale Fenster (nicht mehr das ganze Programm)
- Betrag ändern: Ersatz-Dialog, wenn die Zelle nicht sichtbar ist (vorher passierte still nichts)
- tests/test_gui.py: Oberflächen-Tests inkl. Schließen bei jedem Dialog, Watchdog gegen Hänger
- Build: ein Xvfb für den ganzen Build (Wine bindet sich an das erste DISPLAY), GUI-Tests unter
  Windows-Python, danach Selbsttest der fertigen .exe (--selbsttest)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 10:55:00 +01:00
14 changed files with 1263 additions and 29 deletions

No files matched your search

+3
View File
@@ -13,3 +13,6 @@ dist/
build/tmp/
build/version_info.txt
*.db
selbsttest.txt
/*.jpg
/*.png
+9 -1
View File
@@ -97,9 +97,17 @@ Aufbau:
Gitea Actions (`.gitea/workflows/windows-build.yml`) baut die `.exe` im Container
`tobix/pywine:3.12` (Windows-Python unter Wine) mit PyInstaller:
- jeder Push: Tests unter Windows-Python + `Provisionsmanager95.exe` als Artefakt
- jeder Push: alle Tests unter Windows-Python (Wine) **inklusive Oberflächen-Tests** auf einem
virtuellen Bildschirm, danach `Provisionsmanager95.exe` bauen und mit
`Provisionsmanager95.exe --selbsttest` die **fertige .exe** prüfen; erst dann gibt es das Artefakt
- Tag `vX.Y.Z` (passend zu `provtool/__init__.py`): zusätzlich ein Gitea-Release mit `.exe` und `.zip`
Die Oberflächen-Tests (`tests/test_gui.py`) bedienen die echte Oberfläche: Dialoge öffnen,
Programm schließen, während Assistent/Rückfragen/Rundgang offen sind, Alt+F4, Beträge ändern usw.
Jede Ausnahme in der Oberfläche und jeder Eintrag in `fehler.log` lässt sie fehlschlagen; ein
Watchdog bricht hängende Tests ab, statt den Build zu blockieren. Ohne Bildschirm werden sie lokal
übersprungen, im Build (`PROVTOOL_GUI_TESTS=1`) ist das ein Fehler.
Lokal geht dasselbe mit Docker:
```bash
+28 -2
View File
@@ -9,11 +9,24 @@ export LANG=C.UTF-8 LC_ALL=C.UTF-8 # sonst kann Wine keine Umlaute in Dateinam
export WINEPREFIX="${WINEPREFIX:-/opt/wineprefix}"
PY="wine python"
# Ein virtueller Bildschirm für den GANZEN Build – vor dem ersten Wine-Aufruf starten!
# Wine bindet seinen Hintergrundprozess (wineserver/Desktop) an das DISPLAY des ersten Aufrufs.
# Läuft z. B. „pip install“ ohne Bildschirm und die GUI-Tests später mit, scheitert Tk unter
# Wine mit „Failed to create the menu window“.
wineserver -k 2>/dev/null || true
ANZEIGE=":$((90 + RANDOM % 9))"
Xvfb "$ANZEIGE" -screen 0 1280x800x24 -nolisten tcp >/dev/null 2>&1 &
XVFB_PID=$!
trap 'wineserver -k 2>/dev/null || true; kill $XVFB_PID 2>/dev/null || true' EXIT
export DISPLAY="$ANZEIGE"
sleep 1
$PY --version
$PY -m pip install --disable-pip-version-check -q -r requirements-dev.txt
echo "== Tests (Windows-Python) =="
$PY -m pytest -q
echo "== Tests inkl. Oberfläche (Windows-Python unter Wine) =="
# PROVTOOL_GUI_TESTS=1: fehlt der Bildschirm, ist das ein Fehler (nicht „übersprungen“)
PROVTOOL_GUI_TESTS=1 $PY -m pytest -q -p no:cacheprovider
echo "== Version =="
VERSION=$($PY build/version_info.py | tr -d '\r')
@@ -23,6 +36,19 @@ echo "== PyInstaller =="
$PY -m PyInstaller --noconfirm --clean --distpath dist --workpath build/tmp build/provisionsmanager.spec
test -f dist/Provisionsmanager95.exe
echo "== Selbsttest der fertigen .exe =="
rm -f selbsttest.txt
set +e
timeout 300 wine dist/Provisionsmanager95.exe --selbsttest selbsttest.txt
CODE=$?
set -e
cat selbsttest.txt 2>/dev/null || echo "(keine Ergebnisdatei – die .exe ist vermutlich gar nicht gestartet)"
if [ "$CODE" -ne 0 ] || ! head -1 selbsttest.txt 2>/dev/null | tr -d '\r' | grep -qx OK; then
echo "Selbsttest der .exe FEHLGESCHLAGEN (Exit-Code $CODE)"
exit 1
fi
echo "Selbsttest bestanden"
cp README.md dist/LIESMICH.md 2>/dev/null || true
( cd dist && rm -f Provisionsmanager95-*.zip && \
python3 -c "import zipfile,sys; z=zipfile.ZipFile(sys.argv[1],'w',zipfile.ZIP_DEFLATED); [z.write(f) for f in sys.argv[2:]]; z.close()" \
+61
View File
@@ -0,0 +1,61 @@
"""Erzeugt provtool/gui/bilder/tipp.png (freigestellt, verkleinert, ohne Metadaten) aus dem Foto
für den „Tipp des Tages“. Nur zum Entwickeln nötig (Pillow): python build/make_tipp_bild.py <foto>"""
import sys
from collections import deque
from pathlib import Path
from PIL import Image
ZIEL_HOEHE = 170
ROOT = Path(__file__).resolve().parents[1]
def freistellen(im: Image.Image, schwelle: int = 232, min_flaeche: int = 6000) -> Image.Image:
"""Weißen Hintergrund transparent machen: alle hellen Flächen, die den Rand berühren ODER groß
sind (z. B. das Innere der Büroklammer-Schlaufen). Kleine helle Flächen (Augenweiß) bleiben."""
im = im.convert("RGBA")
w, h = im.size
px = im.load()
hell = lambda p: p[0] >= schwelle and p[1] >= schwelle and p[2] >= schwelle # noqa: E731
gesehen = bytearray(w * h)
for sy in range(h):
for sx in range(w):
if gesehen[sy * w + sx] or not hell(px[sx, sy]):
continue
flaeche, rand, q = [], False, deque([(sx, sy)])
gesehen[sy * w + sx] = 1
while q:
x, y = q.popleft()
flaeche.append((x, y))
if x in (0, w - 1) or y in (0, h - 1):
rand = True
for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
if 0 <= nx < w and 0 <= ny < h and not gesehen[ny * w + nx] and hell(px[nx, ny]):
gesehen[ny * w + nx] = 1
q.append((nx, ny))
if rand or len(flaeche) >= min_flaeche:
for x, y in flaeche:
px[x, y] = (255, 255, 255, 0)
return im.crop(im.getbbox())
def main(foto: str, ziel: Path, modus: str = "lanczos"):
im = freistellen(Image.open(foto))
faktor = ZIEL_HOEHE / im.height
groesse = (max(1, round(im.width * faktor)), ZIEL_HOEHE)
if modus == "raster": # erst auf das Pixel-Raster (ca. 8 px) mitteln, dann hochskalieren
klein = im.resize((im.width // 8, im.height // 8), Image.BOX)
im = klein.resize(groesse, Image.NEAREST)
else:
im = im.resize(groesse, Image.LANCZOS)
# Halbtransparente Randpixel hart machen (Tk/Win95-Optik, keine weißen Säume)
alpha = im.getchannel("A").point(lambda a: 255 if a >= 128 else 0)
im.putalpha(alpha)
ziel.parent.mkdir(parents=True, exist_ok=True)
im.save(ziel, optimize=True) # PNG ohne EXIF-Metadaten
print(ziel, im.size)
if __name__ == "__main__":
main(sys.argv[1], Path(sys.argv[2]) if len(sys.argv) > 2 else ROOT / "provtool" / "gui" / "bilder" / "tipp.png",
sys.argv[3] if len(sys.argv) > 3 else "lanczos")
+1
View File
@@ -8,6 +8,7 @@ a = Analysis(
[os.path.join(ROOT, "build", "start.py")],
pathex=[ROOT],
hiddenimports=["openpyxl", "xlrd"],
datas=[(os.path.join(ROOT, "provtool", "gui", "bilder"), os.path.join("provtool", "gui", "bilder"))],
excludes=["numpy", "pandas", "PIL", "pytest", "matplotlib", "unittest", "pydoc_data"],
noarchive=False,
)
+1 -1
View File
@@ -1,5 +1,5 @@
"""Provisions-Manager 95 – Provisionsabrechnung für Unterhändler der Handy-Insel Miesbach."""
__version__ = "1.1.0"
__version__ = "1.1.3"
APP_NAME = "Provisions-Manager 95"
APP_ORG = "Handy-Insel Miesbach"
+91 -3
View File
@@ -64,6 +64,8 @@ class App:
self.haendler_namen: dict[int, str] = {}
self.ansicht: int = ALLE
self._edit: tk.Entry | None = None
self._schliesst = False # Beenden angefordert (offene Dialoge werden gerade geschlossen)
self._beendet = False # Datenbank und Fenster sind zu
w.apply(root)
self.icons = w.Icons(root)
@@ -594,10 +596,12 @@ class App:
self.ptree.see(iid)
self.ptree.update_idletasks()
bbox = self.ptree.bbox(iid, "ausz")
if not bbox:
return
pid = int(iid[1:])
p = next(x for x in self.positionen if x["id"] == pid)
if not bbox:
# Zelle gerade nicht sichtbar (z. B. Spalte zu schmal) → kleiner Dialog statt Inline-Feld
self._betrag_dialog(p)
return
x, y, bw, bh = bbox
var = tk.StringVar(value=fmt(p["betrag"], symbol=False))
e = tk.Entry(self.ptree, textvariable=var, justify="right", bd=1, relief="solid", font=w.FONT)
@@ -646,6 +650,25 @@ class App:
e.bind("<Escape>", abbruch)
e.bind("<FocusOut>", speichern)
def _betrag_dialog(self, p: dict):
kunde = " ".join(x for x in (p["vorname"], p["name"]) if x) or "(ohne Name)"
while True:
text = w.eingabe(self.root, "Betrag ändern",
f"Auszahlung für {kunde}\n{p['provisionsart']} – Provision {fmt(p['betrag_orig'])}:",
fmt(p["betrag"], symbol=False))
if text is None:
return
try:
cent = parse_cent(text)
except ValueError:
w.warnung(self.root, "Betrag ändern", "Bitte einen Betrag eingeben, z. B. 25 oder 12,50.")
continue
break
if cent != p["betrag"]:
self.db.betrag_setzen(p["id"], cent)
self.db.touch(self.aid)
self._aktualisieren()
def _kontextmenue(self, e):
iid = self.ptree.identify_row(e.y)
if not iid:
@@ -806,6 +829,8 @@ class App:
def assistent(self):
res = ass.Einrichtungsassistent(self).zeigen()
if self._schliesst:
return # Programm wird gerade beendet – beim nächsten Start erscheint der Assistent erneut
self.settings["assistent_erledigt"] = True
config.save(self.settings)
self._aktualisieren()
@@ -821,6 +846,8 @@ class App:
self.root.after(200, self.rundgang)
def rundgang(self):
if self._schliesst:
return
tb = self.tb
schritte = [
(self.rahmen.titelleiste or self.menueleiste, "Willkommen zum Rundgang!",
@@ -868,24 +895,85 @@ class App:
text = "".join(traceback.format_exception(exc, val, tb))
try:
with (config.app_dir() / "fehler.log").open("a", encoding="utf-8") as f:
f.write(text + "\n")
f.write(f"--- {date.today().isoformat()} {APP_NAME}\n{text}\n")
except OSError:
pass
# Beim/nach dem Beenden keine Meldung mehr zeigen – das Fenster gibt es dann nicht mehr
if self._beendet or self._schliesst or not self._lebt():
return
try:
w.fehler(self.root, "Allgemeine Schutzverletzung",
f"Im Programm ist ein Fehler aufgetreten:\n\n{val}\n\n"
f"Details stehen in {config.app_dir() / 'fehler.log'}")
except tk.TclError:
pass
def _lebt(self) -> bool:
try:
return bool(self.root.winfo_exists())
except tk.TclError:
return False
def _offene_fenster(self) -> list[tk.Toplevel]:
"""Alle offenen Dialoge (auch verschachtelte), innerste zuletzt."""
out: list[tk.Toplevel] = []
def suche(widget):
for kind in widget.winfo_children():
if isinstance(kind, tk.Toplevel):
out.append(kind)
suche(kind)
if self._lebt():
suche(self.root)
return out
def beenden(self):
"""Programm schließen – auch wenn gerade Dialoge, Rückfragen oder der Rundgang offen sind.
Offene Dialoge werden zuerst geschlossen; der Code, der auf sie gewartet hat, läuft dann mit
„abgebrochen“ zu Ende (Datenbank noch offen). Neue Dialoge öffnen sich in dieser Phase nicht
mehr. Erst danach werden Einstellungen gespeichert, Datenbank und Fenster geschlossen."""
if self._beendet or not self._lebt():
return
self._schliesst = True
self.root._provtool_schliesst = True # type: ignore[attr-defined] # Signal an win95.Dialog
rg = getattr(self, "_rundgang", None)
if rg is not None:
rg.beenden()
if self._edit is not None:
self._edit.destroy()
self._edit = None
offen = self._offene_fenster()
if offen:
for t in reversed(offen):
try:
if t.winfo_exists():
t.destroy()
except tk.TclError:
pass
self.root.after(30, self.beenden) # wenn alle wartenden Abläufe zurückgekehrt sind
return
self._beendet = True
try:
self.settings["fenster"] = self.rahmen.normale_geometrie()
self.settings["maximiert"] = self.rahmen.maximiert
config.save(self.settings)
except (tk.TclError, OSError):
pass
finally:
try:
self.db.close()
finally:
try:
self.root.destroy()
except tk.TclError:
pass
def main():
if len(sys.argv) > 1 and sys.argv[1] == "--selbsttest":
from provtool.selbsttest import ausfuehren
sys.exit(ausfuehren(sys.argv[2] if len(sys.argv) > 2 else "selbsttest.txt"))
if sys.platform.startswith("win"):
try: # scharfe Darstellung auf HiDPI-Bildschirmen
import ctypes
+58 -17
View File
@@ -9,6 +9,7 @@ from tkinter import filedialog, ttk
from provtool import APP_NAME
from provtool.db import CAPS_ANBIETER, CAPS_WERT, Haendler, ist_grossgeschrieben
from provtool.gui import win32
from provtool.gui import win95 as w
from provtool.importer import lies_datei
from provtool.money import fmt, parse_percent
@@ -406,34 +407,55 @@ class Rundgang:
self.nr = 0
self.blase: tk.Toplevel | None = None
self.rahmen: list[tk.Toplevel] = []
self._sperre = None # Windows: Hauptfenster während des Rundgangs gesperrt (win32.Modal)
self._zeige(0)
def _weg(self):
for r in self.rahmen:
r.destroy()
self.rahmen = []
if self.blase is not None:
@staticmethod
def _entsorgen(blase, rahmen):
for r in rahmen:
try:
self.blase.grab_release()
r.destroy()
except tk.TclError:
pass
self.blase.destroy()
if blase is not None:
try:
blase.grab_release()
except tk.TclError:
pass
try:
blase.destroy()
except tk.TclError:
pass
def _weg(self):
self._entsorgen(self.blase, self.rahmen)
self.rahmen = []
self.blase = None
def beenden(self):
if self._sperre is not None: # vor dem Schließen freigeben, sonst springt Windows woandershin
self._sperre.beenden()
self._sperre = None
self._weg()
def _markieren(self, x, y, bw, bh, dicke=3, farbe="#ff0000"):
for gx, gy, gw, gh in ((x - dicke, y - dicke, bw + 2 * dicke, dicke), (x - dicke, y + bh, bw + 2 * dicke, dicke),
(x - dicke, y, dicke, bh), (x + bw, y, dicke, bh)):
t = tk.Toplevel(self.root, bg=farbe)
t.overrideredirect(True)
t.transient(self.root)
t.overrideredirect(True) # kein transient() – siehe win32.py
t.geometry(f"{max(1, gw)}x{max(1, gh)}+{gx}+{gy}")
t.update_idletasks()
win32.besitzer_setzen(t, self.root) # bleibt über dem Hauptfenster, auch nach Alt+Tab
self.rahmen.append(t)
def _zeige(self, nr: int):
self._weg()
# Die alte Sprechblase verschwindet erst, wenn die neue steht: Wäre kurz kein Popup da,
# während das Hauptfenster gesperrt ist, würde Windows ein fremdes Programm aktivieren.
alt_blase, alt_rahmen = self.blase, self.rahmen
self.blase, self.rahmen = None, []
if getattr(self.root, "_provtool_schliesst", False):
self._entsorgen(alt_blase, alt_rahmen)
return
self.nr = nr
ziel, titel, text = self.schritte[nr]
if callable(ziel) and not isinstance(ziel, tk.Misc):
@@ -444,9 +466,9 @@ class Rundgang:
self._markieren(x, y, bw, bh)
b = self.blase = tk.Toplevel(self.root, bg=w.DARK)
b.abbrechen = self.beenden # Alt+F4 auf der Sprechblase beendet den Rundgang sauber
b.withdraw()
b.overrideredirect(True)
b.transient(self.root)
b.overrideredirect(True) # kein transient() – Besitzer setzt win32.Modal
innen = tk.Frame(b, bg=w.INFO_BG, padx=10, pady=8)
innen.pack(padx=1, pady=1)
kopf = tk.Frame(innen, bg=w.INFO_BG)
@@ -488,6 +510,13 @@ class Rundgang:
b.geometry(f"{pw}x{ph}+{px}+{py}")
b.deiconify()
b.lift()
b.update_idletasks()
b.focus_force()
if self._sperre is None:
self._sperre = win32.Modal(b, self.root)
else:
win32.besitzer_setzen(b, self.root)
self._entsorgen(alt_blase, alt_rahmen)
try:
b.grab_set()
except tk.TclError:
@@ -530,6 +559,17 @@ TIPPS = [
]
BILDER = Path(__file__).resolve().parent / "bilder"
def tipp_bild(master) -> tk.PhotoImage | None:
"""Der Büroklammer-Assistent für den Tipp des Tages (liegt auch in der .exe, siehe .spec)."""
try:
return tk.PhotoImage(master=master, file=str(BILDER / "tipp.png"))
except tk.TclError:
return None
class TippDesTages(w.Dialog):
def __init__(self, app):
super().__init__(app.root, "Tipp des Tages")
@@ -538,16 +578,17 @@ class TippDesTages(w.Dialog):
haupt.pack(padx=12, pady=12)
box = tk.Frame(haupt, bg=w.WHITE, bd=2, relief="sunken")
box.pack(side="left", fill="both")
links = tk.Frame(box, bg=w.SHADOW, width=60)
links.pack(side="left", fill="y")
self._ic = w.make_icon(self, "birne", 3)
tk.Label(links, image=self._ic, bg=w.SHADOW).pack(padx=6, pady=10)
self._bild = tipp_bild(self)
self.bild_ok = self._bild is not None
if self._bild is None: # Ersatz, falls das Bild fehlt
self._bild = w.make_icon(self, "birne", 3)
tk.Label(box, image=self._bild, bg=w.WHITE).pack(side="left", anchor="n", padx=(12, 4), pady=10)
rechts = tk.Frame(box, bg=w.WHITE, padx=12, pady=10)
rechts.pack(side="left", fill="both")
tk.Label(rechts, text="Wussten Sie schon…", bg=w.WHITE, font=(w.FONT_FAMILY, w.FONT_SIZE + 6, "bold"),
anchor="w").pack(fill="x")
tk.Frame(rechts, bg=w.DARK, height=1).pack(fill="x", pady=6)
self.text = tk.Label(rechts, bg=w.WHITE, justify="left", anchor="nw", wraplength=330, width=48, height=6)
self.text = tk.Label(rechts, bg=w.WHITE, justify="left", anchor="nw", wraplength=330, width=48, height=8)
self.text.pack(fill="both")
knoepfe = tk.Frame(haupt, bg=w.FACE)
knoepfe.pack(side="left", anchor="n", padx=(10, 0))
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+44 -1
View File
@@ -255,9 +255,52 @@ class Hauptrahmen:
self.aussen.bind("<Leave>", lambda e: self.aussen.configure(cursor=""))
self.aussen.bind("<ButtonPress-1>", self._resize_start)
self.aussen.bind("<B1-Motion>", self._resize)
root.bind_all("<Alt-F4>", lambda e: schliessen())
root.bind_all("<Alt-F4>", lambda e: self.alt_f4(e.widget))
root.after(50, lambda: taskleiste_anzeigen(root))
def alt_f4(self, widget) -> str:
"""Alt+F4 wie unter Windows: schließt nur das Fenster, in dem man gerade ist."""
try:
# Ist ein modaler Dialog offen, gilt Alt+F4 immer ihm – egal wo der Tastaturfokus liegt
# (unter Windows liefert Tk Tastendrücke an das Fenster mit Fokus, das kann noch das
# Hauptfenster sein).
sperre = self.root.grab_current()
popup = self._offenes_popup()
if sperre is not None and sperre.winfo_toplevel() is not self.root:
fenster = sperre.winfo_toplevel()
elif popup is not None: # Sperre (noch) nicht gesetzt – trotzdem gilt Alt+F4 dem Popup
fenster = popup
else:
fenster = widget.winfo_toplevel() if isinstance(widget, tk.Misc) else self.root
except tk.TclError:
fenster = self.root
if fenster is self.root:
self._schliessen()
elif hasattr(fenster, "abbrechen"):
fenster.abbrechen()
else:
fenster.destroy()
return "break"
def _offenes_popup(self):
"""Innerstes sichtbares Popup mit abbrechen() (Dialog, Rückfrage, Rundgang-Sprechblase)."""
gefunden = []
def suche(widget):
for kind in widget.winfo_children():
# Fensterstatus statt winfo_ismapped(): direkt nach dem Einblenden ist der Status schon
# „normal“, gezeichnet ist das Fenster aber evtl. noch nicht (langsamer PC, Wine).
if isinstance(kind, tk.Toplevel) and hasattr(kind, "abbrechen"):
try:
offen = kind.winfo_exists() and kind.wm_state() != "withdrawn"
except tk.TclError:
offen = False
if offen:
gefunden.append(kind)
suche(kind)
suche(self.root)
return gefunden[-1] if gefunden else None
# ---- öffentliche Helfer
def set_titel(self, text: str):
self.root.title(text)
+133
View File
@@ -0,0 +1,133 @@
"""Win32-Hilfen für modale Fenster (nur unter Windows wirksam, sonst ohne Funktion).
Wichtig: Rahmenlose Fenster NICHT zusätzlich mit Tk `transient()` versehen – Tk legt sie dann als
Kindfenster des Desktops (WS_CHILD) an, und denen kann Windows keinen Besitzer geben.
Rahmenlose Tk-Fenster (overrideredirect) gehören für Windows zu keinem anderen Fenster. Holt man das
Programm per Taskleiste oder Alt+Tab zurück, kommt dann nur das Hauptfenster nach vorn – ein offener
Dialog bliebe dahinter verborgen. Deshalb machen wir es wie Windows bei echten modalen Dialogen:
der Dialog bekommt das aufrufende Fenster als *Besitzer*, und der Besitzer wird solange *deaktiviert*.
Windows bringt dann bei Klick aufs Hauptfenster, Taskleiste und Alt+Tab automatisch den Dialog nach vorn.
"""
from __future__ import annotations
import sys
import tkinter as tk
IS_WIN = sys.platform.startswith("win")
GWLP_HWNDPARENT = -8
GW_OWNER = 4
_u32 = None
def user32():
"""user32 mit korrekten 64-Bit-Signaturen (Fensterhandles dürfen nicht abgeschnitten werden)."""
global _u32
if _u32 is None:
import ctypes
from ctypes import wintypes
u = ctypes.WinDLL("user32", use_last_error=True)
u.GetParent.argtypes = [wintypes.HWND]
u.GetParent.restype = wintypes.HWND
u.SetWindowLongPtrW.argtypes = [wintypes.HWND, ctypes.c_int, ctypes.c_void_p]
u.SetWindowLongPtrW.restype = ctypes.c_void_p
u.EnableWindow.argtypes = [wintypes.HWND, wintypes.BOOL]
u.EnableWindow.restype = wintypes.BOOL
u.IsWindowEnabled.argtypes = [wintypes.HWND]
u.IsWindowEnabled.restype = wintypes.BOOL
u.GetWindow.argtypes = [wintypes.HWND, wintypes.UINT]
u.GetWindow.restype = wintypes.HWND
u.GetLastActivePopup.argtypes = [wintypes.HWND]
u.GetLastActivePopup.restype = wintypes.HWND
u.SetForegroundWindow.argtypes = [wintypes.HWND]
u.SetForegroundWindow.restype = wintypes.BOOL
u.IsWindow.argtypes = [wintypes.HWND]
u.IsWindow.restype = wintypes.BOOL
u.GetAncestor.argtypes = [wintypes.HWND, wintypes.UINT]
u.GetAncestor.restype = wintypes.HWND
u.GetTopWindow.argtypes = [wintypes.HWND]
u.GetTopWindow.restype = wintypes.HWND
u.BringWindowToTop.argtypes = [wintypes.HWND]
u.BringWindowToTop.restype = wintypes.BOOL
_u32 = u
return _u32
def hwnd(fenster: tk.Misc) -> int | None:
"""Handle des äußeren (obersten) Windows-Fensters eines Tk-Toplevels."""
if not IS_WIN:
return None
try:
return user32().GetAncestor(fenster.winfo_toplevel().winfo_id(), 2) # GA_ROOT
except (tk.TclError, OSError):
return None
def stapel(*fenster: tk.Misc) -> list[int]:
"""Reihenfolge der übergebenen Fenster im Windows-Fensterstapel, oberstes zuerst (Indizes)."""
ziele = [hwnd(f) for f in fenster]
out, w = [], user32().GetTopWindow(None)
while w:
if w in ziele:
out.append(ziele.index(w))
w = user32().GetWindow(w, 2) # GW_HWNDNEXT
return out
def nach_vorn(fenster: tk.Misc) -> None:
h = hwnd(fenster)
if h:
user32().SetForegroundWindow(h)
user32().BringWindowToTop(h)
def besitzer_setzen(fenster: tk.Misc, besitzer: tk.Misc) -> None:
h, b = hwnd(fenster), hwnd(besitzer)
if h and b:
user32().SetWindowLongPtrW(h, GWLP_HWNDPARENT, b)
def besitzer_von(fenster: tk.Misc) -> int | None:
h = hwnd(fenster)
return user32().GetWindow(h, GW_OWNER) if h else None
def bedienbar(fenster: tk.Misc, an: bool) -> None:
h = hwnd(fenster)
if h and user32().IsWindow(h):
user32().EnableWindow(h, bool(an))
def ist_bedienbar(fenster: tk.Misc) -> bool:
h = hwnd(fenster)
return bool(user32().IsWindowEnabled(h)) if h else True
def letztes_popup(fenster: tk.Misc) -> int | None:
h = hwnd(fenster)
return user32().GetLastActivePopup(h) if h else None
class Modal:
"""Macht `fenster` modal zu `besitzer` (Windows-Art). beenden() muss VOR dem Zerstören des
Fensters laufen: erst Besitzer wieder freigeben, dann schließen – sonst aktiviert Windows
beim Schließen ein fremdes Programm statt unseres Hauptfensters."""
def __init__(self, fenster: tk.Misc, besitzer: tk.Misc, sperren: bool = True):
self.besitzer = besitzer
self.gesperrt = False
if not IS_WIN:
return
besitzer_setzen(fenster, besitzer)
if sperren and ist_bedienbar(besitzer):
bedienbar(besitzer, False)
self.gesperrt = True
def beenden(self) -> None:
if self.gesperrt:
self.gesperrt = False
try:
bedienbar(self.besitzer, True)
except tk.TclError:
pass
+33 -4
View File
@@ -508,9 +508,13 @@ class Dialog(tk.Toplevel):
super().__init__(master, bg=FACE)
self.withdraw()
self.title(titel)
if not KLASSISCH:
# Rahmenlose Dialoge bekommen ihren Besitzer über win32.Modal – mit transient() würde Tk
# sie unter Windows als Kindfenster anlegen, die dann hinter anderen Programmen hängen.
self.transient(master.winfo_toplevel())
self.resizable(resizable, resizable)
self.result = None
self._modal = None # Windows: Besitzer + Sperre des aufrufenden Fensters (siehe win32.Modal)
self.protocol("WM_DELETE_WINDOW", self.abbrechen)
self.bind("<Escape>", lambda e: self.abbrechen())
try:
@@ -526,6 +530,9 @@ class Dialog(tk.Toplevel):
self.titelleiste.pack(side="top", fill="x")
def zeigen(self, fokus=None):
if getattr(self._root(), "_provtool_schliesst", False):
self.destroy() # Programm wird beendet – keine neuen Dialoge mehr
return None
self.update_idletasks()
m = self.master.winfo_toplevel()
w, h = self.winfo_reqwidth(), self.winfo_reqheight()
@@ -534,11 +541,14 @@ class Dialog(tk.Toplevel):
self.geometry(f"+{max(0, x)}+{max(0, y)}")
self.deiconify()
self.lift()
# Windows: Dialog gehört dem aufrufenden Fenster, das solange gesperrt ist → Klick aufs
# Hauptfenster, Taskleiste und Alt+Tab holen automatisch den Dialog nach vorn.
from provtool.gui.win32 import Modal
self.update_idletasks()
self.focus_force()
self._modal = Modal(self, m)
vorher = self.grab_current() # z. B. der Assistent, wenn hier eine Rückfrage aufgeht
try:
self.grab_set()
except tk.TclError:
pass
self._sperre_setzen()
bind_id = None
if KLASSISCH:
# Ein rahmenloser Dialog darf nicht hinter dem Hauptfenster verschwinden
@@ -560,6 +570,25 @@ class Dialog(tk.Toplevel):
pass
return self.result
def _sperre_setzen(self, versuch: int = 0):
"""Tk-Modalsperre setzen. Ist das Fenster noch nicht ganz sichtbar (langsamer PC, Wine),
schlägt grab_set fehl – dann kurz danach erneut versuchen statt ohne Sperre weiterzumachen."""
try:
if self.winfo_exists():
self.grab_set()
except tk.TclError:
if versuch < 50:
self.after(20, lambda: self._sperre_setzen(versuch + 1))
def destroy(self):
# Erst das aufrufende Fenster wieder freigeben, dann schließen – sonst aktiviert Windows
# beim Schließen ein fremdes Programm statt unseres Fensters.
modal = getattr(self, "_modal", None)
if modal is not None:
modal.beenden()
self._modal = None
super().destroy()
def abbrechen(self):
self.result = None
self.destroy()
+182
View File
@@ -0,0 +1,182 @@
"""Selbsttest der fertigen Programmdatei: `Provisionsmanager95.exe --selbsttest ergebnis.txt`
Läuft im Build (Wine + virtueller Bildschirm) gegen die tatsächlich ausgelieferte .exe. Startet die
echte Oberfläche in einem Wegwerf-Profil, spielt kritische Abläufe durch und schreibt „OK“ bzw. die
gefundenen Fehler in die Ergebnisdatei (Exit-Code 0 = bestanden).
Geprüft wird u. a. der Fall „Programm schließen, während der Einrichtungs-Assistent offen ist“
sowie Import und Export (openpyxl/xlrd müssen in der .exe enthalten sein).
"""
from __future__ import annotations
import os
import sys
import tempfile
import time
import tkinter as tk
import traceback
from pathlib import Path
ZEITLIMIT = 60 # Sekunden je Szenario
class _Szenario:
def __init__(self, name: str, home: Path, einstellungen: str | None = None):
self.name, self.home, self.fehler = name, home, []
home.mkdir(parents=True, exist_ok=True)
os.environ["PROVTOOL_HOME"] = str(home)
if einstellungen:
(home / "einstellungen.json").write_text(einstellungen, encoding="utf-8")
from provtool.gui.app import App
self.root = tk.Tk()
self.app = 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)
except Exception:
self.fehler.append("Fehler-Handler ist abgestürzt:\n" + traceback.format_exc())
self.root.report_callback_exception = melden
def lebt(self) -> bool:
try:
return bool(self.root.winfo_exists())
except tk.TclError:
return False
def dialog(self, typ):
from provtool.gui import win95 as w
gefunden = []
def suche(widget):
for kind in widget.winfo_children():
if isinstance(kind, w.Dialog) and isinstance(kind, typ) and kind.winfo_exists():
gefunden.append(kind)
suche(kind)
if self.lebt():
suche(self.root)
return gefunden[0] if gefunden else None
def ablauf(self, schritte):
offen, start = list(schritte), time.time()
def poll():
if not self.lebt():
return
if time.time() - start > ZEITLIMIT:
self.fehler.append(f"Zeitüberschreitung bei: {offen[0][2] if offen else 'Ende'}")
try:
self.root.destroy()
except tk.TclError:
pass
return
if offen:
try:
bereit = offen[0][0]()
except Exception:
bereit = False
if bereit:
_, aktion, _ = offen.pop(0)
def ausfuehren(a=aktion):
try:
a()
except Exception:
self.fehler.append(traceback.format_exc())
self.root.after(0, ausfuehren)
self.root.after(20, poll)
self.root.after(20, poll)
self.root.mainloop()
if offen:
self.fehler.append("Nicht ausgeführt: " + ", ".join(s[2] for s in offen))
log = self.home / "fehler.log"
if log.exists() and log.read_text(encoding="utf-8").strip():
self.fehler.append("fehler.log:\n" + log.read_text(encoding="utf-8"))
return [f"[{self.name}] {f}" for f in self.fehler]
def _beispiel_csv(pfad: Path):
kopf = ("Zeitraum;Verguetungsart;Haendlernummer;Unterhaendler;Vertragsnummer;Aktiv;Deaktiv;Rufnummer;"
"Kartennummer;IMEI_SN;Geraetename;Kundenvorname;Kundename;Tarif-Kurz;Tarif;Prozent;Betrag")
zeilen = [kopf,
"2026.10.01-2026.10.07;SIM-Karten;1;111;1;05.10.2026;;1601111111;;;;;Muster GmbH;;Allnet;0;8",
"2026.10.01-2026.10.07;Sonderbonus Aktion;1;111;2;06.10.2026;;1602222222;;;;;Beispiel KG;;Classic;0;170"]
pfad.write_bytes(("\r\n".join(zeilen) + "\r\n").encode("cp1252"))
def ausfuehren(ergebnisdatei: str) -> int:
from provtool.gui.assistent import Einrichtungsassistent
basis = Path(tempfile.mkdtemp(prefix="pm95-selbsttest-"))
fehler: list[str] = []
immer = lambda: True # noqa: E731
try:
# 1) Gemeldeter Absturz: Schließen, während der Einrichtungs-Assistent offen ist
s = _Szenario("Schließen bei offenem Assistenten", basis / "a")
cmd = s.root.tk.call("wm", "protocol", s.root._w, "WM_DELETE_WINDOW")
fehler += s.ablauf([(lambda: s.dialog(Einrichtungsassistent) is not None, lambda: None, "Assistent erscheint"),
(immer, lambda: s.root.tk.call(cmd), "Fenster schließen")])
# 2) Assistent komplett, Import, Export, Rundgang, Beenden
csv = basis / "freenet.csv"
_beispiel_csv(csv)
s = _Szenario("Assistent, Import, Export", basis / "b")
wiz = lambda: s.dialog(Einrichtungsassistent) # noqa: E731
seite = lambda n: (lambda: wiz() is not None and wiz().nr == n) # noqa: E731
def haendler():
wiz().v_name.set("Selbsttest-Händler")
wiz().v_fn.set("111")
wiz()._haendler_dazu()
def abrechnung():
wiz().dateien.append(str(csv))
wiz().v_rundgang.set(False)
wiz()._weiter()
def import_bericht_zu():
from provtool.gui.dialogs import ImportBericht
s.dialog(ImportBericht).abbrechen()
def export():
from provtool import export as exp
app = s.app
pos = [p for p in app.positionen if p["haendler_id"] > 0]
dateien = exp.exportiere_haendler(basis / "export", "Selbsttest", pos, "Test")
if len(pos) != 2 or len(dateien) != 1 or not dateien[0].exists():
s.fehler.append(f"Export fehlerhaft: {len(pos)} Positionen, Dateien {dateien}")
from provtool.gui.assistent import TippDesTages
from provtool.gui.dialogs import ImportBericht
def tipp_pruefen():
d = s.dialog(TippDesTages)
if not d.bild_ok:
s.fehler.append("Bild für den Tipp des Tages fehlt in der .exe (provtool/gui/bilder/tipp.png)")
d.abbrechen()
fehler += s.ablauf([
(seite(0), lambda: wiz()._weiter(), "Willkommen"),
(seite(1), lambda: wiz()._weiter(), "Speicherort"),
(seite(2), haendler, "Händler anlegen"),
(lambda: len(s.app.db.haendler_liste()) == 1, lambda: wiz()._weiter(), "Händler → weiter"),
(seite(3), abrechnung, "Abrechnung mit Datei"),
(seite(4), lambda: wiz()._weiter(), "Fertig stellen"),
(lambda: s.dialog(ImportBericht) is not None, import_bericht_zu, "Import-Bericht schließen"),
(lambda: len(s.app.positionen) == 2, export, "Export"),
(immer, s.app.tipp_des_tages, "Tipp des Tages öffnen"),
(lambda: s.dialog(TippDesTages) is not None, tipp_pruefen, "Tipp-Bild in der .exe"),
(lambda: s.dialog(TippDesTages) is None, s.app.rundgang, "Rundgang"),
(lambda: getattr(s.app, "_rundgang", None) is not None, s.app.beenden, "Beenden während Rundgang"),
])
except Exception:
fehler.append(traceback.format_exc())
Path(ergebnisdatei).write_text("OK\n" if not fehler else "FEHLER\n\n" + "\n\n".join(fehler), encoding="utf-8")
return 0 if not fehler else 1
if __name__ == "__main__":
sys.exit(ausfuehren(sys.argv[1] if len(sys.argv) > 1 else "selbsttest.txt"))
+619
View File
@@ -0,0 +1,619 @@
"""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
# ---------------------------------------------------------------- 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 = list(schritte)
start = time.time()
zustand = {"fertig_seit": None}
def poll():
if not self.lebt:
return
if time.time() - start > sekunden:
text = offen[0][2] if offen else "(nach dem letzten Schritt hängt ein Dialog)"
self.fehler.append(f"Zeitüberschreitung – hängt bei: {text}")
self._notaus()
return
if offen:
bed, akt, _ = offen[0]
try:
bereit = bed()
except Exception:
bereit = False
if bereit:
offen.pop(0)
self.root.after(0, akt)
elif zustand["fertig_seit"] is None:
zustand["fertig_seit"] = time.time()
self.root.after(20, poll)
self.root.after(20, poll)
while self.lebt:
try:
self.root.update()
except tk.TclError:
break
f = zustand["fertig_seit"]
if f is not None and time.time() - f >= nachlauf:
break
time.sleep(0.01)
self.uebrig = [s[2] for s in offen]
def schliessen_ueber_fenster(self):
"""Wie Klick auf × / Taskleiste ▸ Fenster schließen (WM_DELETE_WINDOW)."""
cmd = self.root.tk.call("wm", "protocol", self.root._w, "WM_DELETE_WINDOW")
self.root.tk.call(cmd)
def pruefen(self):
log = self.home / "fehler.log"
inhalt = log.read_text(encoding="utf-8") if log.exists() else ""
assert not self.fehler, "Fehler in der Oberfläche:\n\n" + "\n".join(self.fehler)
assert not inhalt, "fehler.log wurde geschrieben:\n" + inhalt
def ende(self):
if self.lebt:
self.ablauf([(IMMER, self.app.beenden, "beenden")], sekunden=10)
@pytest.fixture
def home(tmp_path, monkeypatch):
monkeypatch.setenv("PROVTOOL_HOME", str(tmp_path))
return tmp_path
@pytest.fixture
def lauf_mit_daten(home):
"""App mit einem Händler und einer Abrechnung (erfundene Daten)."""
from tests.test_kern import FREENET_KOPF, freenet_zeile
csv = home / "freenet.csv"
csv.write_bytes(("\r\n".join([
FREENET_KOPF,
freenet_zeile("SIM-Karten", "111", "05.10.2026", "1601111111", "Muster GmbH", "Allnet", "8"),
freenet_zeile("Sonderbonus Aktion", "111", "06.10.2026", "1602222222", "Beispiel KG", "Classic", "170"),
freenet_zeile("SIM-Karten", "999", "07.10.2026", "1603333333", "Eigener Kunde", "Flex", "8"),
]) + "\r\n").encode("cp1252"))
db = Datenbank(config.db_path())
db.haendler_speichern(Haendler(0, "Händler A"), [("freenet", "111")])
from provtool.importer import lies_datei
db.importieren(db.abrechnung_neu("Oktober 2026"), lies_datei(csv))
db.close()
lauf = Lauf(home, {"assistent_erledigt": True, "tipps_beim_start": False})
yield lauf
lauf.ende()
def _assistent_offen(lauf):
from provtool.gui.assistent import Einrichtungsassistent
return lambda: lauf.dialog(Einrichtungsassistent) is not None
# =========================================================================== Schließen bei offenem Dialog
@pytest.mark.parametrize("weg", ["fenster", "beenden"])
def test_schliessen_waehrend_assistent(home, weg):
"""Reproduziert den gemeldeten Absturz: Programm schließen, während der Assistent offen ist."""
lauf = Lauf(home) # frisches Profil → Assistent startet automatisch
zu = {"fenster": lauf.schliessen_ueber_fenster, "beenden": lauf.app.beenden}[weg]
lauf.ablauf([(_assistent_offen(lauf), lambda: None, "Assistent erscheint"),
(IMMER, zu, f"schließen über {weg}")])
assert not lauf.lebt, "Programm wurde nicht beendet"
lauf.pruefen()
def test_alt_f4_im_assistent_fragt_und_schliesst_nur_assistent(home):
"""Alt+F4 bei offenem Assistenten = „Abbrechen“ mit Rückfrage; das Programm bleibt offen."""
from provtool.gui.assistent import Einrichtungsassistent
lauf = Lauf(home)
rueckfrage = lambda: next((d for d in lauf.dialoge() if d.title() == "Einrichtung abbrechen"), None) # noqa: E731
def ja_klicken():
d = rueckfrage()
d.result = True
d.destroy()
lauf.ablauf([
(_assistent_offen(lauf), lambda: lauf.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.uebrig
assert lauf.app.settings.get("assistent_erledigt") is True
lauf.pruefen()
lauf.ende()
lauf.pruefen()
def test_alt_f4_im_dialog_schliesst_nur_den_dialog(lauf_mit_daten, 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()),
}
@pytest.mark.parametrize("name", ["haendler", "abrechnungen", "export", "einstellungen", "neu", "info", "tipp",
"dateien", "hilfe", "assistent", "prozent", "neu_berechnen"])
def test_schliessen_waehrend_dialog_offen(lauf_mit_daten, name):
lauf = lauf_mit_daten
oeffnen = _dialog_oeffner()[name]
lauf.ablauf([(IMMER, lambda: oeffnen(lauf.app), f"{name} öffnen"),
(lambda: bool(lauf.dialoge()), lauf.schliessen_ueber_fenster, "Programm schließen")])
assert not lauf.lebt, f"Programm wurde nicht beendet (offen: {lauf.uebrig})"
lauf.pruefen()
def test_schliessen_waehrend_rueckfrage_im_assistent(home):
"""Assistent offen → „Abbrechen“ → Rückfrage offen → Programm schließen."""
from provtool.gui.assistent import Einrichtungsassistent
lauf = Lauf(home)
lauf.ablauf([
(_assistent_offen(lauf), lambda: lauf.dialog(Einrichtungsassistent).abbrechen(), "Abbrechen klicken"),
(lambda: len(lauf.dialoge()) >= 2, lauf.schliessen_ueber_fenster, "Programm schließen"),
])
assert not lauf.lebt
lauf.pruefen()
def test_schliessen_waehrend_rundgang(lauf_mit_daten):
lauf = lauf_mit_daten
lauf.ablauf([
(IMMER, lauf.app.rundgang, "Rundgang starten"),
(lambda: getattr(lauf.app, "_rundgang", None) and lauf.app._rundgang.blase, lauf.schliessen_ueber_fenster,
"Programm schließen"),
])
assert not lauf.lebt
lauf.pruefen()
def test_alt_f4_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, "_sperre_setzen", lambda self, versuch=0: None) # keine Sperre
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.uebrig
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.uebrig
lauf.pruefen()
def test_betrag_dialog_wenn_zelle_unsichtbar(lauf_mit_daten):
"""Ist die Betragszelle nicht sichtbar, öffnet sich ein Dialog statt still nichts zu tun."""
lauf = lauf_mit_daten
app = lauf.app
hid = next(h.id for h in app.db.haendler_liste())
eingabe = lambda: next((d for d in lauf.dialoge() if d.title() == "Betrag ändern"), None) # noqa: E731
def tippen():
d = eingabe()
entry = next(k for k in d.winfo_children() if isinstance(k, tk.Entry))
entry.delete(0, "end")
entry.insert(0, "7,77")
d.result = entry.get()
d.destroy()
def oeffnen():
pos = app.ptree.get_children(app.ptree.get_children()[0])[0]
app.ptree.bbox = lambda *a, **k: "" # Zelle „unsichtbar“
app.betrag_bearbeiten(pos)
lauf.ablauf([
(IMMER, lambda: app.htree.selection_set(str(hid)), "Händler wählen"),
(lambda: bool(app.ptree.get_children()), oeffnen, "Betrag ändern bei unsichtbarer Zelle"),
(lambda: eingabe() is not None, tippen, "Betrag im Dialog eingeben"),
])
assert not lauf.uebrig, lauf.uebrig
assert any(p["betrag"] == 777 and p["manuell"] for p in app.positionen)
del app.ptree.bbox
lauf.pruefen()
def test_fehler_handler_nach_zerstoertem_fenster(home):
"""Der Fehler-Handler darf nicht abstürzen, wenn das Fenster schon weg ist – nur protokollieren."""
lauf = Lauf(home, {"assistent_erledigt": True, "tipps_beim_start": False})
app = lauf.app
lauf.root.destroy()
try:
raise RuntimeError("Testfehler")
except RuntimeError as exc:
app._fehler_handler(type(exc), exc, exc.__traceback__) # darf nicht werfen
assert "RuntimeError: Testfehler" in (home / "fehler.log").read_text(encoding="utf-8")
def test_zweimal_beenden(lauf_mit_daten):
lauf = lauf_mit_daten
lauf.ablauf([(IMMER, lambda: (lauf.app.beenden(), lauf.app.beenden()), "zweimal beenden")])
assert not lauf.lebt
lauf.pruefen()
# =========================================================================== Rauchtests der Abläufe
def test_assistent_komplett_durchklicken(home):
from provtool.gui.assistent import Einrichtungsassistent
lauf = Lauf(home)
wiz = lambda: lauf.dialog(Einrichtungsassistent) # noqa: E731
def haendler_eintragen():
wiz().v_name.set("Testhändler")
wiz().v_fn.set("111 (3 Pos., 10,00 €)")
wiz().v_caps.set(True)
assert wiz()._haendler_dazu()
seite = lambda n: (lambda: wiz() is not None and wiz().nr == n) # noqa: E731
lauf.ablauf([
(seite(0), lambda: wiz()._weiter(), "Willkommen → Speicherort"),
(seite(1), lambda: wiz()._weiter(), "Speicherort → Händler"),
(seite(2), haendler_eintragen, "Händler eintragen"),
(lambda: len(lauf.app.db.haendler_liste()) == 1, lambda: wiz()._zurueck(), "zurück"),
(seite(1), lambda: wiz()._weiter(), "wieder vor"),
(seite(2), lambda: wiz()._weiter(), "Händler → Abrechnung"),
(seite(3), lambda: wiz()._weiter(), "Abrechnung → Fertig"),
(seite(4), lambda: wiz()._weiter(), "Fertig stellen"),
(lambda: getattr(lauf.app, "_rundgang", None) is not None and lauf.app._rundgang.blase is not None,
lambda: [lauf.app._rundgang._weiter() for _ in range(len(lauf.app._rundgang.schritte))],
"Rundgang komplett durchklicken"),
])
assert not lauf.uebrig, f"nicht ausgeführt: {lauf.uebrig}"
db = lauf.app.db
h = db.haendler_liste()
assert [x.name for x in h] == ["Testhändler"]
assert ("vodafone_caps", "*") in db.kennungen(h[0].id)
assert [a["titel"] for a in db.abrechnungen()]
assert lauf.app._rundgang.blase is None
lauf.pruefen()
lauf.ende()
lauf.pruefen()
def test_hauptfenster_ablaeufe(lauf_mit_daten):
from provtool.gui.dialogs import ProzentDialog
lauf = lauf_mit_daten
app = lauf.app
hid = next(h.id for h in app.db.haendler_liste())
def betrag_aendern():
# Feld öffnen und sofort tippen: ohne Fenstermanager (Xvfb/Wine) verliert das Feld sonst
# gleich den Fokus und speichert unverändert – genau wie beim Wegklicken.
pos = app.ptree.get_children(app.ptree.get_children()[0])[0]
app.betrag_bearbeiten(pos)
lauf.root.update_idletasks()
e = app._edit
e.focus_force()
e.delete(0, "end")
e.insert(0, "5,55")
e.event_generate("<Return>")
if app._edit is e:
# Unter Windows gehen erzeugte Tastendrücke an das Fenster mit Fokus – ohne Fenster-
# manager hat das Feld ihn evtl. nicht. Dann wie „wegklicken“ speichern (FocusOut).
e.event_generate("<FocusOut>")
def prozent_ok():
d = lauf.dialog(ProzentDialog)
d.v.set("80")
d._ok()
def fenster():
if app.rahmen.klassisch:
app.rahmen.maximieren()
app.rahmen.wiederherstellen()
lauf.ablauf([
(IMMER, lambda: app.htree.selection_set(str(hid)), "Händler wählen"),
(lambda: bool(app.ptree.get_children())
and bool(app.ptree.bbox(app.ptree.get_children(app.ptree.get_children()[0])[0], "ausz")),
betrag_aendern, "Betrag inline ändern (sobald die Zeile sichtbar ist)"),
(lambda: any(p["betrag"] == 555 and p["manuell"] for p in app.positionen),
lambda: (app.ptree.selection_set(app.ptree.get_children()[-1]), app.prozent()), "Prozent-Dialog"),
(lambda: lauf.dialog(ProzentDialog) is not None, prozent_ok, "80 % setzen"),
(lambda: lauf.dialog(ProzentDialog) is None, lambda: app.v_suche.set("Beispiel"), "suchen"),
(IMMER, lambda: app.v_suche.set(""), "Suche leeren"),
(IMMER, fenster, "maximieren/wiederherstellen"),
])
assert not lauf.uebrig, f"nicht ausgeführt: {lauf.uebrig}"
assert any(p["prozent"] == 80 for p in app.positionen)
lauf.pruefen()
# =========================================================================== 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))
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.uebrig
assert m["besitzer"] == m["haupt"], "Popup gehört nicht dem Hauptfenster"
assert m["haupt_bedienbar"] is False, "Hauptfenster muss gesperrt sein, solange das Popup offen ist"
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()
@nur_windows
def test_windows_verschachtelte_rueckfrage(lauf_mit_daten):
"""Händler-Dialog → Rückfrage: Rückfrage gehört dem Händler-Dialog; beim Schließen der Reihe nach frei."""
from provtool.gui import 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
def messen_innen():
h, r = lauf.dialog(HaendlerDialog), rueckfrage()
m.update(r_besitzer=win32.besitzer_von(r), h=win32.hwnd(h), h_an=win32.ist_bedienbar(h),
haupt_an=win32.ist_bedienbar(lauf.root))
r.destroy()
def messen_aussen():
h = lauf.dialog(HaendlerDialog)
m.update(h_an_danach=win32.ist_bedienbar(h), haupt_an_danach=win32.ist_bedienbar(lauf.root))
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.uebrig
assert m["r_besitzer"] == m["h"], "Rückfrage gehört nicht dem Händler-Dialog"
assert m["h_an"] is False and m["haupt_an"] is False
assert m["h_an_danach"] is True and m["haupt_an_danach"] is False
assert win32.ist_bedienbar(lauf.root)
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.uebrig
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()