1 Commits
Author SHA1 Message Date
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
10 changed files with 415 additions and 27 deletions

No files matched your search

+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")], [os.path.join(ROOT, "build", "start.py")],
pathex=[ROOT], pathex=[ROOT],
hiddenimports=["openpyxl", "xlrd"], 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"], excludes=["numpy", "pandas", "PIL", "pytest", "matplotlib", "unittest", "pydoc_data"],
noarchive=False, noarchive=False,
) )
+1 -1
View File
@@ -1,5 +1,5 @@
"""Provisions-Manager 95 – Provisionsabrechnung für Unterhändler der Handy-Insel Miesbach.""" """Provisions-Manager 95 – Provisionsabrechnung für Unterhändler der Handy-Insel Miesbach."""
__version__ = "1.1.1" __version__ = "1.1.2"
APP_NAME = "Provisions-Manager 95" APP_NAME = "Provisions-Manager 95"
APP_ORG = "Handy-Insel Miesbach" APP_ORG = "Handy-Insel Miesbach"
+53 -21
View File
@@ -9,6 +9,7 @@ from tkinter import filedialog, ttk
from provtool import APP_NAME from provtool import APP_NAME
from provtool.db import CAPS_ANBIETER, CAPS_WERT, Haendler, ist_grossgeschrieben 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.gui import win95 as w
from provtool.importer import lies_datei from provtool.importer import lies_datei
from provtool.money import fmt, parse_percent from provtool.money import fmt, parse_percent
@@ -406,41 +407,54 @@ class Rundgang:
self.nr = 0 self.nr = 0
self.blase: tk.Toplevel | None = None self.blase: tk.Toplevel | None = None
self.rahmen: list[tk.Toplevel] = [] self.rahmen: list[tk.Toplevel] = []
self._sperre = None # Windows: Hauptfenster während des Rundgangs gesperrt (win32.Modal)
self._zeige(0) self._zeige(0)
def _weg(self): @staticmethod
for r in self.rahmen: def _entsorgen(blase, rahmen):
for r in rahmen:
try: try:
r.destroy() r.destroy()
except tk.TclError: except tk.TclError:
pass pass
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.rahmen = []
if self.blase is not None:
try:
self.blase.grab_release()
except tk.TclError:
pass
try:
self.blase.destroy()
except tk.TclError:
pass
self.blase = None self.blase = None
def beenden(self): 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() self._weg()
def _markieren(self, x, y, bw, bh, dicke=3, farbe="#ff0000"): 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), 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)): (x - dicke, y, dicke, bh), (x + bw, y, dicke, bh)):
t = tk.Toplevel(self.root, bg=farbe) t = tk.Toplevel(self.root, bg=farbe)
t.overrideredirect(True) t.overrideredirect(True) # kein transient() – siehe win32.py
t.transient(self.root)
t.geometry(f"{max(1, gw)}x{max(1, gh)}+{gx}+{gy}") 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) self.rahmen.append(t)
def _zeige(self, nr: int): 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): if getattr(self.root, "_provtool_schliesst", False):
self._entsorgen(alt_blase, alt_rahmen)
return return
self.nr = nr self.nr = nr
ziel, titel, text = self.schritte[nr] ziel, titel, text = self.schritte[nr]
@@ -454,8 +468,7 @@ class Rundgang:
b = self.blase = tk.Toplevel(self.root, bg=w.DARK) b = self.blase = tk.Toplevel(self.root, bg=w.DARK)
b.abbrechen = self.beenden # Alt+F4 auf der Sprechblase beendet den Rundgang sauber b.abbrechen = self.beenden # Alt+F4 auf der Sprechblase beendet den Rundgang sauber
b.withdraw() b.withdraw()
b.overrideredirect(True) b.overrideredirect(True) # kein transient() – Besitzer setzt win32.Modal
b.transient(self.root)
innen = tk.Frame(b, bg=w.INFO_BG, padx=10, pady=8) innen = tk.Frame(b, bg=w.INFO_BG, padx=10, pady=8)
innen.pack(padx=1, pady=1) innen.pack(padx=1, pady=1)
kopf = tk.Frame(innen, bg=w.INFO_BG) kopf = tk.Frame(innen, bg=w.INFO_BG)
@@ -497,6 +510,13 @@ class Rundgang:
b.geometry(f"{pw}x{ph}+{px}+{py}") b.geometry(f"{pw}x{ph}+{px}+{py}")
b.deiconify() b.deiconify()
b.lift() 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: try:
b.grab_set() b.grab_set()
except tk.TclError: except tk.TclError:
@@ -539,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): class TippDesTages(w.Dialog):
def __init__(self, app): def __init__(self, app):
super().__init__(app.root, "Tipp des Tages") super().__init__(app.root, "Tipp des Tages")
@@ -547,16 +578,17 @@ class TippDesTages(w.Dialog):
haupt.pack(padx=12, pady=12) haupt.pack(padx=12, pady=12)
box = tk.Frame(haupt, bg=w.WHITE, bd=2, relief="sunken") box = tk.Frame(haupt, bg=w.WHITE, bd=2, relief="sunken")
box.pack(side="left", fill="both") box.pack(side="left", fill="both")
links = tk.Frame(box, bg=w.SHADOW, width=60) self._bild = tipp_bild(self)
links.pack(side="left", fill="y") self.bild_ok = self._bild is not None
self._ic = w.make_icon(self, "birne", 3) if self._bild is None: # Ersatz, falls das Bild fehlt
tk.Label(links, image=self._ic, bg=w.SHADOW).pack(padx=6, pady=10) 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 = tk.Frame(box, bg=w.WHITE, padx=12, pady=10)
rechts.pack(side="left", fill="both") 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"), tk.Label(rechts, text="Wussten Sie schon…", bg=w.WHITE, font=(w.FONT_FAMILY, w.FONT_SIZE + 6, "bold"),
anchor="w").pack(fill="x") anchor="w").pack(fill="x")
tk.Frame(rechts, bg=w.DARK, height=1).pack(fill="x", pady=6) 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") self.text.pack(fill="both")
knoepfe = tk.Frame(haupt, bg=w.FACE) knoepfe = tk.Frame(haupt, bg=w.FACE)
knoepfe.pack(side="left", anchor="n", padx=(10, 0)) knoepfe.pack(side="left", anchor="n", padx=(10, 0))
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+15
View File
@@ -265,8 +265,11 @@ class Hauptrahmen:
# (unter Windows liefert Tk Tastendrücke an das Fenster mit Fokus, das kann noch das # (unter Windows liefert Tk Tastendrücke an das Fenster mit Fokus, das kann noch das
# Hauptfenster sein). # Hauptfenster sein).
sperre = self.root.grab_current() sperre = self.root.grab_current()
popup = self._offenes_popup()
if sperre is not None and sperre.winfo_toplevel() is not self.root: if sperre is not None and sperre.winfo_toplevel() is not self.root:
fenster = sperre.winfo_toplevel() fenster = sperre.winfo_toplevel()
elif popup is not None: # Sperre (noch) nicht gesetzt – trotzdem gilt Alt+F4 dem Popup
fenster = popup
else: else:
fenster = widget.winfo_toplevel() if isinstance(widget, tk.Misc) else self.root fenster = widget.winfo_toplevel() if isinstance(widget, tk.Misc) else self.root
except tk.TclError: except tk.TclError:
@@ -279,6 +282,18 @@ class Hauptrahmen:
fenster.destroy() fenster.destroy()
return "break" 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():
if isinstance(kind, tk.Toplevel) and hasattr(kind, "abbrechen") and kind.winfo_ismapped():
gefunden.append(kind)
suche(kind)
suche(self.root)
return gefunden[-1] if gefunden else None
# ---- öffentliche Helfer # ---- öffentliche Helfer
def set_titel(self, text: str): def set_titel(self, text: str):
self.root.title(text) 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
+30 -4
View File
@@ -508,9 +508,13 @@ class Dialog(tk.Toplevel):
super().__init__(master, bg=FACE) super().__init__(master, bg=FACE)
self.withdraw() self.withdraw()
self.title(titel) 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.transient(master.winfo_toplevel())
self.resizable(resizable, resizable) self.resizable(resizable, resizable)
self.result = None self.result = None
self._modal = None # Windows: Besitzer + Sperre des aufrufenden Fensters (siehe win32.Modal)
self.protocol("WM_DELETE_WINDOW", self.abbrechen) self.protocol("WM_DELETE_WINDOW", self.abbrechen)
self.bind("<Escape>", lambda e: self.abbrechen()) self.bind("<Escape>", lambda e: self.abbrechen())
try: try:
@@ -537,11 +541,14 @@ class Dialog(tk.Toplevel):
self.geometry(f"+{max(0, x)}+{max(0, y)}") self.geometry(f"+{max(0, x)}+{max(0, y)}")
self.deiconify() self.deiconify()
self.lift() 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 vorher = self.grab_current() # z. B. der Assistent, wenn hier eine Rückfrage aufgeht
try: self._sperre_setzen()
self.grab_set()
except tk.TclError:
pass
bind_id = None bind_id = None
if KLASSISCH: if KLASSISCH:
# Ein rahmenloser Dialog darf nicht hinter dem Hauptfenster verschwinden # Ein rahmenloser Dialog darf nicht hinter dem Hauptfenster verschwinden
@@ -563,6 +570,25 @@ class Dialog(tk.Toplevel):
pass pass
return self.result 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): def abbrechen(self):
self.result = None self.result = None
self.destroy() self.destroy()
+11 -1
View File
@@ -148,7 +148,15 @@ def ausfuehren(ergebnisdatei: str) -> int:
if len(pos) != 2 or len(dateien) != 1 or not dateien[0].exists(): if len(pos) != 2 or len(dateien) != 1 or not dateien[0].exists():
s.fehler.append(f"Export fehlerhaft: {len(pos)} Positionen, Dateien {dateien}") s.fehler.append(f"Export fehlerhaft: {len(pos)} Positionen, Dateien {dateien}")
from provtool.gui.assistent import TippDesTages
from provtool.gui.dialogs import ImportBericht 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([ fehler += s.ablauf([
(seite(0), lambda: wiz()._weiter(), "Willkommen"), (seite(0), lambda: wiz()._weiter(), "Willkommen"),
(seite(1), lambda: wiz()._weiter(), "Speicherort"), (seite(1), lambda: wiz()._weiter(), "Speicherort"),
@@ -158,7 +166,9 @@ def ausfuehren(ergebnisdatei: str) -> int:
(seite(4), lambda: wiz()._weiter(), "Fertig stellen"), (seite(4), lambda: wiz()._weiter(), "Fertig stellen"),
(lambda: s.dialog(ImportBericht) is not None, import_bericht_zu, "Import-Bericht schließen"), (lambda: s.dialog(ImportBericht) is not None, import_bericht_zu, "Import-Bericht schließen"),
(lambda: len(s.app.positionen) == 2, export, "Export"), (lambda: len(s.app.positionen) == 2, export, "Export"),
(immer, s.app.rundgang, "Rundgang"), (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"), (lambda: getattr(s.app, "_rundgang", None) is not None, s.app.beenden, "Beenden während Rundgang"),
]) ])
except Exception: except Exception:
+110
View File
@@ -448,3 +448,113 @@ def test_hauptfenster_ablaeufe(lauf_mit_daten):
assert not lauf.uebrig, f"nicht ausgeführt: {lauf.uebrig}" assert not lauf.uebrig, f"nicht ausgeführt: {lauf.uebrig}"
assert any(p["prozent"] == 80 for p in app.positionen) assert any(p["prozent"] == 80 for p in app.positionen)
lauf.pruefen() 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()