Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26ac90bb0f | ||
|
|
aae28fa7f2 |
No files matched your search
@@ -13,3 +13,6 @@ dist/
|
||||
build/tmp/
|
||||
build/version_info.txt
|
||||
*.db
|
||||
selbsttest.txt
|
||||
/*.jpg
|
||||
/*.png
|
||||
@@ -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
@@ -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()" \
|
||||
|
||||
@@ -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")
|
||||
@@ -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,5 +1,5 @@
|
||||
"""Provisions-Manager 95 – Provisionsabrechnung für Unterhändler der Handy-Insel Miesbach."""
|
||||
|
||||
__version__ = "1.1.0"
|
||||
__version__ = "1.1.2"
|
||||
APP_NAME = "Provisions-Manager 95"
|
||||
APP_ORG = "Handy-Insel Miesbach"
|
||||
+96
-8
@@ -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
|
||||
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'}")
|
||||
# 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)
|
||||
self.db.close()
|
||||
except (tk.TclError, OSError):
|
||||
pass
|
||||
finally:
|
||||
self.root.destroy()
|
||||
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
|
||||
|
||||
+59
-18
@@ -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()
|
||||
self.blase = None
|
||||
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 |
+37
-1
@@ -255,9 +255,45 @@ 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():
|
||||
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
|
||||
def set_titel(self, text: str):
|
||||
self.root.title(text)
|
||||
|
||||
@@ -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
|
||||
+34
-5
@@ -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("<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()
|
||||
|
||||
@@ -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"))
|
||||
@@ -0,0 +1,560 @@
|
||||
"""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] = []
|
||||
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
|
||||
@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):
|
||||
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.dialog(Einrichtungsassistent).event_generate("<Alt-F4>"), "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, "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):
|
||||
from provtool.gui.dialogs import InfoDialog
|
||||
lauf = lauf_mit_daten
|
||||
lauf.ablauf([
|
||||
(IMMER, lambda: InfoDialog(lauf.root).zeigen(), "Info öffnen"),
|
||||
(lambda: lauf.dialog(InfoDialog) is not None,
|
||||
lambda: lauf.dialog(InfoDialog).event_generate("<Alt-F4>"), "Alt+F4 im Dialog"),
|
||||
(lambda: lauf.dialog(InfoDialog) is None, lambda: None, "Dialog geschlossen"),
|
||||
], sekunden=8)
|
||||
assert lauf.lebt, "Alt+F4 im Dialog darf nicht das ganze Programm beenden"
|
||||
assert not lauf.uebrig, lauf.uebrig
|
||||
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_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: rg().blase.event_generate("<Alt-F4>"), "Alt+F4"),
|
||||
(lambda: rg().blase is None and not rg().rahmen, lambda: None, "Rundgang samt Rahmen weg"),
|
||||
], sekunden=8)
|
||||
assert lauf.lebt, "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()
|
||||
Reference in new issue
Block a user