From 26ac90bb0f88ff902955091f29322459d7337d71 Mon Sep 17 00:00:00 2001 From: Handy-Insel Date: Mon, 2 Nov 2026 11:26:17 +0100 Subject: [PATCH] =?UTF-8?q?Popups=20kommen=20nach=20Klick=20woanders=20/?= =?UTF-8?q?=20Alt+Tab=20wieder=20nach=20vorn;=20B=C3=BCroklammer=20im=20Ti?= =?UTF-8?q?pp=20des=20Tages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- build/make_tipp_bild.py | 61 ++++++++++++++++ build/provisionsmanager.spec | 1 + provtool/__init__.py | 2 +- provtool/gui/assistent.py | 76 ++++++++++++++------ provtool/gui/bilder/tipp.png | Bin 0 -> 25319 bytes provtool/gui/rahmen.py | 15 ++++ provtool/gui/win32.py | 133 +++++++++++++++++++++++++++++++++++ provtool/gui/win95.py | 36 ++++++++-- provtool/selbsttest.py | 12 +++- tests/test_gui.py | 110 +++++++++++++++++++++++++++++ 10 files changed, 417 insertions(+), 29 deletions(-) create mode 100644 build/make_tipp_bild.py create mode 100644 provtool/gui/bilder/tipp.png create mode 100644 provtool/gui/win32.py diff --git a/build/make_tipp_bild.py b/build/make_tipp_bild.py new file mode 100644 index 0000000..da693e7 --- /dev/null +++ b/build/make_tipp_bild.py @@ -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 """ +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") diff --git a/build/provisionsmanager.spec b/build/provisionsmanager.spec index f2b795f..c3ea6ac 100644 --- a/build/provisionsmanager.spec +++ b/build/provisionsmanager.spec @@ -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, ) diff --git a/provtool/__init__.py b/provtool/__init__.py index c72c481..5f80904 100644 --- a/provtool/__init__.py +++ b/provtool/__init__.py @@ -1,5 +1,5 @@ """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_ORG = "Handy-Insel Miesbach" diff --git a/provtool/gui/assistent.py b/provtool/gui/assistent.py index db83167..1027cc2 100644 --- a/provtool/gui/assistent.py +++ b/provtool/gui/assistent.py @@ -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,41 +407,54 @@ 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: + @staticmethod + def _entsorgen(blase, rahmen): + for r in rahmen: try: r.destroy() except tk.TclError: 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 = [] - 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): + 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] @@ -454,8 +468,7 @@ class Rundgang: 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) @@ -497,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: @@ -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): def __init__(self, app): super().__init__(app.root, "Tipp des Tages") @@ -547,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)) diff --git a/provtool/gui/bilder/tipp.png b/provtool/gui/bilder/tipp.png new file mode 100644 index 0000000000000000000000000000000000000000..87a9f388eb92bb330f1b30eaaaca7c96b82b13b6 GIT binary patch literal 25319 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 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 diff --git a/provtool/gui/win95.py b/provtool/gui/win95.py index bd93e16..6f7f408 100644 --- a/provtool/gui/win95.py +++ b/provtool/gui/win95.py @@ -508,9 +508,13 @@ class Dialog(tk.Toplevel): super().__init__(master, bg=FACE) self.withdraw() self.title(titel) - self.transient(master.winfo_toplevel()) + 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("", lambda e: self.abbrechen()) try: @@ -537,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 @@ -563,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() diff --git a/provtool/selbsttest.py b/provtool/selbsttest.py index 51ac9d6..c062d1b 100644 --- a/provtool/selbsttest.py +++ b/provtool/selbsttest.py @@ -148,7 +148,15 @@ def ausfuehren(ergebnisdatei: str) -> int: 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"), @@ -158,7 +166,9 @@ def ausfuehren(ergebnisdatei: str) -> int: (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.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"), ]) except Exception: diff --git a/tests/test_gui.py b/tests/test_gui.py index 3ea5a53..6c2437f 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -448,3 +448,113 @@ def test_hauptfenster_ablaeufe(lauf_mit_daten): 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()