diff --git a/.gitea/workflows/windows-build.yml b/.gitea/workflows/windows-build.yml index 7918414..f1fd953 100644 --- a/.gitea/workflows/windows-build.yml +++ b/.gitea/workflows/windows-build.yml @@ -23,7 +23,7 @@ jobs: - name: Werkzeuge für Checkout/Upload installieren run: | apt-get update -qq - apt-get install -y -qq --no-install-recommends git nodejs ca-certificates >/dev/null + apt-get install -y -qq --no-install-recommends git nodejs ca-certificates xdotool >/dev/null - uses: actions/checkout@v4 diff --git a/build/build_windows.sh b/build/build_windows.sh index 1847267..52df544 100755 --- a/build/build_windows.sh +++ b/build/build_windows.sh @@ -29,6 +29,9 @@ echo "== Tests inkl. Oberfläche (Windows-Python unter Wine) ==" # -v: im Log ist live sichtbar, welcher Test läuft; timeout: Obergrenze für alle Tests zusammen PROVTOOL_GUI_TESTS=1 timeout 900 $PY -m pytest -v -p no:cacheprovider +echo "== Maustest (echte Maus: Fenster ziehen, Rand ziehen, maximiert ziehen) ==" +PY="$PY" timeout 300 bash build/maustest.sh + echo "== Version ==" VERSION=$($PY build/version_info.py | tr -d '\r') echo "Version $VERSION" diff --git a/build/maus_app.py b/build/maus_app.py new file mode 100644 index 0000000..f45cbc0 --- /dev/null +++ b/build/maus_app.py @@ -0,0 +1,36 @@ +"""Für build/maustest.sh: startet die App und schreibt laufend Lage und Zustand des Hauptfensters. + +Kein pytest-Test – wird im Windows-Build mit echter Maus (xdotool) bedient. Hintergrund: v1.3.0 stürzte +beim Ziehen an der Titelleiste hart ab; das sieht man nur mit echter Maus. +""" +import faulthandler +import json +import os +import sys +import tempfile +import tkinter as tk + +aus = sys.argv[1] +faulthandler.enable(open(os.path.join(aus, "absturz.txt"), "w")) +home = tempfile.mkdtemp() +os.environ["PROVTOOL_HOME"] = home +with open(os.path.join(home, "einstellungen.json"), "w") as f: + json.dump({"assistent_erledigt": True, "tipps_beim_start": False, "fenster": "900x560+100+100"}, f) +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from provtool.gui import app as A # noqa: E402 + +root = tk.Tk() +a = A.App(root) +fehler = [] +root.report_callback_exception = lambda *e: fehler.append(repr(e[1])) + + +def melden(): + with open(os.path.join(aus, "status.txt"), "a") as f: + f.write(f"{root.winfo_x()} {root.winfo_y()} {root.winfo_width()} {root.winfo_height()} " + f"{int(a.rahmen.maximiert)} {len(fehler)}\n") + root.after(250, melden) + + +root.after(250, melden) +root.mainloop() diff --git a/build/maustest.sh b/build/maustest.sh new file mode 100755 index 0000000..3d50461 --- /dev/null +++ b/build/maustest.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Echte Maus im virtuellen Bildschirm (xdotool): Fenster an der Titelleiste ziehen, am Rand die Größe +# ziehen, maximieren und aus „maximiert“ herausziehen. v1.3.0 stürzte dabei hart ab – das sieht nur +# ein Test mit echter Maus (Windows-Verschiebeschleife). Aufruf aus build_windows.sh (DISPLAY gesetzt). +set -uo pipefail +cd "$(dirname "$0")/.." +PY="${PY:-wine python}" +AUS="$(mktemp -d)" +command -v xdotool >/dev/null || { apt-get update -qq >/dev/null && apt-get install -y -qq xdotool >/dev/null; } +command -v xdotool >/dev/null || { echo "xdotool fehlt"; exit 1; } + +$PY build/maus_app.py "$(winepath -w "$AUS" 2>/dev/null || echo "$AUS")" > "$AUS/app.log" 2>&1 & +PID=$! +for i in $(seq 1 120); do [ -s "$AUS/status.txt" ] && break; sleep 0.5; done +sleep 3 +lage() { tail -1 "$AUS/status.txt" | tr -d '\r'; } +lebt() { kill -0 "$PID" 2>/dev/null; } +fehler=0 +pruefe() { # $1 = Schritt + sleep 1.5 + if ! lebt; then echo "ABSTURZ bei: $1"; fehler=1; return 1; fi + echo "$1: $(lage)" +} +ziehen() { # x y dx dy + xdotool mousemove "$1" "$2"; sleep 0.4; xdotool mousedown 1; sleep 0.3 + for i in 1 2 3 4 5 6; do xdotool mousemove_relative -- "$(( $3 / 6 ))" "$(( $4 / 6 ))"; sleep 0.15; done + sleep 0.2; xdotool mouseup 1 +} + +read -r X Y W H M F <<< "$(lage)" +echo "Start: $X $Y $W $H maximiert=$M" +ziehen $(( X + 300 )) $(( Y + 14 )) 60 42; pruefe "Titelleiste ziehen" || true +read -r X2 Y2 W2 H2 M2 F2 <<< "$(lage)" +[ "$X2" -ne "$X" ] || [ "$Y2" -ne "$Y" ] || { echo "Fenster hat sich nicht bewegt"; fehler=1; } +ziehen $(( X2 + W2 - 2 )) $(( Y2 + H2 / 2 )) 60 0; pruefe "Rand ziehen" || true +read -r X3 Y3 W3 H3 M3 F3 <<< "$(lage)" +[ "$W3" -gt "$W2" ] || { echo "Größe am Rand nicht geändert ($W2 -> $W3)"; fehler=1; } +xdotool mousemove $(( X3 + 300 )) $(( Y3 + 14 )); sleep 0.3 +xdotool click --repeat 2 --delay 60 1; pruefe "Doppelklick (maximieren)" || true +read -r X4 Y4 W4 H4 M4 F4 <<< "$(lage)" +[ "$M4" = "1" ] || { echo "nicht maximiert"; fehler=1; } +# Unter echtem Windows wird das Fenster dabei wiederhergestellt, unter Wine nicht – hier zählt: kein Absturz +ziehen $(( X4 + 400 )) $(( Y4 + 12 )) 0 150; pruefe "aus maximiert herausziehen" || true + +if lebt; then kill "$PID" 2>/dev/null; fi +sleep 1 +if [ -s "$AUS/absturz.txt" ]; then echo "== Absturzprotokoll:"; cat "$AUS/absturz.txt"; fehler=1; fi +if grep -q "Fatal Python error" "$AUS/app.log"; then cat "$AUS/app.log"; fehler=1; fi +read -r _ _ _ _ _ FE <<< "$(lage)" +[ "${FE:-0}" = "0" ] || { echo "Fehler in Tk-Callbacks: $FE"; fehler=1; } +[ "$fehler" = 0 ] && echo "Maustest bestanden" || echo "Maustest FEHLGESCHLAGEN" +exit "$fehler" diff --git a/provtool/__init__.py b/provtool/__init__.py index 32e1165..493e232 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.3.0" +__version__ = "1.3.1" APP_NAME = "Provisions-Manager 95" APP_ORG = "Handy-Insel Miesbach" diff --git a/provtool/export.py b/provtool/export.py index 7b32a96..7d2b424 100644 --- a/provtool/export.py +++ b/provtool/export.py @@ -45,6 +45,14 @@ def dateiname(text: str) -> str: AIRTIME_TEXT = "Airtime" +def spalte_a(p: dict): + """Spalte A: bei freenet der Zeitraum, bei Vodafone das Aktivierungsdatum (dort steht als Zeitraum + nur der Abrechnungslauf wie „VF2609“ – kein Datum).""" + if p.get("anbieter") == "vodafone" and p.get("datum"): + return date.fromisoformat(p["datum"]) + return p.get("zeitraum") or None + + def airtime_zeile(airtime: list[dict]) -> dict: """Eine Zeile „Airtime = Summe“ für die Abrechnung.""" zeitraeume = sorted({p.get("zeitraum") or "" for p in airtime} - {""}) @@ -76,7 +84,7 @@ def schreibe_xlsx(ziel: str | Path, positionen: list[dict], *, kopfzeile: bool = erste = zeile for p in sortiert(positionen) + list(schluss or []): werte = [ - p.get("zeitraum") or None, + spalte_a(p), p.get("provisionsart") or None, _zahl_oder_text(p.get("kennung")), date.fromisoformat(p["datum"]) if p.get("datum") else None, @@ -95,6 +103,8 @@ def schreibe_xlsx(ziel: str | Path, positionen: list[dict], *, kopfzeile: bool = cell = ws.cell(zeile, c, v) cell.font = bold if p.get("_summe") and c in (2, 11) else font ws.cell(zeile, 4).number_format = "DD.MM.YYYY" + if isinstance(werte[0], date): + ws.cell(zeile, 1).number_format = "DD.MM.YYYY" ws.cell(zeile, 5).number_format = "0" ws.cell(zeile, 6).number_format = "0" ws.cell(zeile, 11).number_format = EURO_FORMAT diff --git a/provtool/gui/nativ.py b/provtool/gui/nativ.py index a653fca..15aa673 100644 --- a/provtool/gui/nativ.py +++ b/provtool/gui/nativ.py @@ -11,11 +11,17 @@ Größenänderungen also weiter ohne Rahmenaufschlag (sonst würde das Fenster b Größenanpassung um die Rahmenbreite wachsen). Windows erledigt alles Weitere selbst: - Ziehen an der Titelleiste (WM_NCLBUTTONDOWN/HTCAPTION) mit Andocken links/rechts/oben (Maximieren) - Win+Pfeiltasten, Doppelklick, Systemmenü (Alt+Leertaste), Taskleiste, Alt+Tab -- Größe ändern: Am Rand meldet das Fenster per WM_NCHITTEST „linker Rand“ usw. -> Windows zeigt den - passenden Mauszeiger und zieht die Größe selbst (flüssig, kein hängender Zeiger). +- Größe ändern: Den Größen-Mauszeiger am Rand zeigt Tk (rahmen.py) – so weiß Tk immer, welcher Zeiger + gilt, und setzt ihn beim Zurückfahren in das Fenster wieder richtig. Beim Drücken auf den Rand zieht + Windows die Größe (flüssig). -WICHTIG: Die Fensterprozeduren hier laufen mitten in Tk. Sie dürfen Tk NICHT aufrufen (Tk-Sperre -> -Verklemmung), nur Win32-Funktionen. +WICHTIG: +- Die Fensterprozeduren hier laufen mitten in Tk. Sie dürfen Tk NICHT aufrufen (Tk-Sperre -> + Verklemmung), nur Win32-Funktionen. +- Verschieben/Größe ziehen NIE synchron (SendMessage) aus einem Tk-Ereignis heraus starten: Windows + läuft dann in einer eigenen Schleife, in der Tk Python-Ereignisse (z. B. ) ausführt, + während Python das nicht erwartet -> harter Absturz („PyEval_RestoreThread … thread state is + NULL“). Deshalb PostMessage: Die Schleife startet, sobald Tk wieder in seiner Ereignisschleife ist. """ from __future__ import annotations @@ -27,15 +33,13 @@ from ctypes import wintypes IS_WIN = sys.platform.startswith("win") WM_NCDESTROY, WM_NCCALCSIZE, WM_NCHITTEST, WM_NCACTIVATE = 0x0082, 0x0083, 0x0084, 0x0086 -WM_NCLBUTTONDOWN, WM_SYSCOMMAND, WM_SETCURSOR = 0x00A1, 0x0112, 0x0020 -HTCLIENT, HTCAPTION, HTTRANSPARENT = 1, 2, -1 +WM_NCLBUTTONDOWN, WM_SYSCOMMAND = 0x00A1, 0x0112 +HTCLIENT, HTCAPTION = 1, 2 HT = {"w": 10, "e": 11, "n": 12, "nw": 13, "ne": 14, "s": 15, "sw": 16, "se": 17} GWL_STYLE, GWL_EXSTYLE = -16, -20 WS_CAPTION, WS_THICKFRAME, WS_SYSMENU = 0x00C00000, 0x00040000, 0x00080000 WS_MINIMIZEBOX, WS_MAXIMIZEBOX = 0x00020000, 0x00010000 WS_EX_TOOLWINDOW, WS_EX_APPWINDOW = 0x00000080, 0x00040000 -ZEIGER = {"n": 32645, "s": 32645, "w": 32644, "e": 32644, "nw": 32642, "se": 32642, "ne": 32643, "sw": 32643} -HT_RAND = {v: k for k, v in HT.items()} SWP_NOSIZE, SWP_NOMOVE, SWP_NOZORDER, SWP_NOACTIVATE, SWP_FRAMECHANGED = 0x1, 0x2, 0x4, 0x10, 0x20 SC_MAXIMIZE, SC_RESTORE, SC_MINIMIZE = 0xF030, 0xF120, 0xF020 @@ -73,9 +77,7 @@ def _api(): ("GetMonitorInfoW", [wintypes.HMONITOR, ctypes.POINTER(MONITORINFO)], wintypes.BOOL), ("ReleaseCapture", [], wintypes.BOOL), ("SendMessageW", [H, U, W, L], LRESULT), ("PostMessageW", [H, U, W, L], wintypes.BOOL), ("ShowWindow", [H, ctypes.c_int], wintypes.BOOL), - ("WindowFromPoint", [wintypes.POINT], H), - ("LoadCursorW", [wintypes.HINSTANCE, ctypes.c_void_p], wintypes.HANDLE), - ("SetCursor", [wintypes.HANDLE], wintypes.HANDLE)): + ("WindowFromPoint", [wintypes.POINT], H)): f = getattr(u, name) f.argtypes, f.restype = args, res c.SetWindowSubclass.argtypes = [H, SUBCLASSPROC, ctypes.c_size_t, ctypes.c_size_t] @@ -109,11 +111,7 @@ def arbeitsbereich_von(rect: wintypes.RECT) -> wintypes.RECT | None: class NativerRahmen: - """Macht das Tk-Hauptfenster zu einem echten Windows-Fenster ohne sichtbaren Windows-Rahmen. - - rand_widgets: Tk-Widgets, die am Fensterrand liegen (deren Randbereich wird für die Maus - „durchsichtig“, damit Windows dort den Größenrahmen erkennt). - """ + """Macht das Tk-Hauptfenster zu einem echten Windows-Fenster ohne sichtbaren Windows-Rahmen.""" def __init__(self, root: tk.Tk, rand: int, ecke: int): self.root = root @@ -121,11 +119,10 @@ class NativerRahmen: self.hwnd = None self.aktiv = False self._procs: dict[int, object] = {} # Referenzen halten, sonst räumt Python die Callbacks weg - self._rand_hwnds: set[int] = set() # ------------------------------------------------------------------ Einrichten - def einrichten(self, rand_widgets: list[tk.Misc]) -> bool: - """Nach dem ersten Anzeigen aufrufen (vorher gibt es das äußere Windows-Fenster noch nicht).""" + def einrichten(self) -> bool: + """Sobald Tk das äußere Windows-Fenster angelegt hat (möglichst vor dem ersten Anzeigen).""" if self.aktiv: return True try: @@ -142,10 +139,6 @@ class NativerRahmen: u.SetWindowLongW(h, GWL_EXSTYLE, (ex & ~WS_EX_TOOLWINDOW) | WS_EX_APPWINDOW) if not self._unterklasse(h, self._aussen_proc): return False - for wdg in rand_widgets: - hw = wdg.winfo_id() - if self._unterklasse(hw, self._rand_proc): - self._rand_hwnds.add(hw) self.aktiv = True # Rahmen neu berechnen lassen (jetzt: unsichtbar) u.SetWindowPos(h, None, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE @@ -167,10 +160,9 @@ class NativerRahmen: """Hat Tk das äußere Fenster neu angelegt (z. B. nach „wm resizable“)? Dann neu einrichten.""" return self.aktiv and self.aeusseres_fenster() not in (None, self.hwnd) - def neu_einrichten(self, rand_widgets: list[tk.Misc]) -> bool: + def neu_einrichten(self) -> bool: self.aktiv = False - self._rand_hwnds.clear() - return self.einrichten(rand_widgets) + return self.einrichten() def _unterklasse(self, h: int, fn) -> bool: _, c = _api() @@ -229,27 +221,12 @@ class NativerRahmen: if rand: return HT[rand] return HTCLIENT - if msg == WM_SETCURSOR and (lparam & 0xFFFF) in HT_RAND: - u = _api()[0] - u.SetCursor(u.LoadCursorW(None, ZEIGER[HT_RAND[lparam & 0xFFFF]])) - return 1 if msg == WM_NCDESTROY: self._entfernen(hwnd) except Exception: # niemals eine Ausnahme in die Fensterprozedur durchlassen pass return c.DefSubclassProc(hwnd, msg, wparam, lparam) - def _rand_proc(self, hwnd, msg, wparam, lparam, _id, _ref): - _, c = _api() - try: - if msg == WM_NCHITTEST and self.randbereich(*_xy(lparam)): - return HTTRANSPARENT # Maus „fällt durch“ zum äußeren Fenster -> Größenrahmen - if msg == WM_NCDESTROY: - self._entfernen(hwnd) - except Exception: - pass - return c.DefSubclassProc(hwnd, msg, wparam, lparam) - def _entfernen(self, hwnd): proc = self._procs.pop(hwnd, None) if proc is not None: @@ -262,15 +239,16 @@ class NativerRahmen: # ------------------------------------------------------------------ Bedienung def verschieben(self) -> None: - """Titelleiste gedrückt: Windows zieht das Fenster (mit Andocken an den Bildschirmrand).""" + """Titelleiste gedrückt: Windows zieht das Fenster (mit Andocken an den Bildschirmrand). + PostMessage, nicht SendMessage – siehe Modulbeschreibung.""" u, _ = _api() u.ReleaseCapture() - u.SendMessageW(self.hwnd, WM_NCLBUTTONDOWN, HTCAPTION, 0) + u.PostMessageW(self.hwnd, WM_NCLBUTTONDOWN, HTCAPTION, 0) def groesse_ziehen(self, rand: str) -> None: u, _ = _api() u.ReleaseCapture() - u.SendMessageW(self.hwnd, WM_NCLBUTTONDOWN, HT[rand], 0) + u.PostMessageW(self.hwnd, WM_NCLBUTTONDOWN, HT[rand], 0) def maximiert(self) -> bool: return bool(self.hwnd and _api()[0].IsZoomed(self.hwnd)) @@ -298,10 +276,6 @@ class NativerRahmen: u, _ = _api() return u.GetWindowLongW(self.hwnd, GWL_STYLE), u.GetWindowLongW(self.hwnd, GWL_EXSTYLE) - def fenster_bei(self, x: int, y: int) -> int | None: - """Für Tests: Welches Fenster bekommt die Maus am Bildschirmpunkt (berücksichtigt HTTRANSPARENT)?""" - return _api()[0].WindowFromPoint(wintypes.POINT(x, y)) - def trefferpruefung(self, hwnd: int, x: int, y: int) -> int: """Für Tests: Was meldet das Fenster `hwnd` für den Bildschirmpunkt (WM_NCHITTEST)?""" lp = ((y & 0xFFFF) << 16) | (x & 0xFFFF) diff --git a/provtool/gui/rahmen.py b/provtool/gui/rahmen.py index 5a7583f..5de100d 100644 --- a/provtool/gui/rahmen.py +++ b/provtool/gui/rahmen.py @@ -290,9 +290,9 @@ class Hauptrahmen: except tk.TclError: sichtbar = True if self.nativ.veraltet(): # Tk hat das äußere Fenster neu angelegt - ok = self.nativ.neu_einrichten([self.root, self.aussen]) + ok = self.nativ.neu_einrichten() else: - ok = self.nativ.einrichten([self.root, self.aussen]) + ok = self.nativ.einrichten() if ok: # Erst eingerichtet, als das Fenster schon zu sehen war -> Taskleiste muss es neu erfassen self._taskleiste_neu = sichtbar @@ -502,7 +502,8 @@ class Hauptrahmen: "sw": "bottom_left_corner"} def _rand_cursor(self, e): - if self.maximiert or self.nativ_an: # nativ: Windows zeigt den Zeiger selbst + if self.maximiert: + self._cursor_zurueck() return rand = self._welcher_rand(e.x, e.y) zeiger = (self.CURSOR_WIN if IS_WIN else self.CURSOR_X11).get(rand, "") @@ -515,7 +516,8 @@ class Hauptrahmen: def _resize_start(self, e, rand: str | None = None): self._rand = rand or self._welcher_rand(e.x, e.y) if self._rand and self.nativ_an and not self.maximiert: - # sollte nicht vorkommen (der Rand ist für Windows durchlässig) – dann eben so + # Den Zeiger zeigt Tk (weiß dann immer, welcher gilt); ziehen lässt Windows (flüssig, + # mit Andocken). Angestoßen per PostMessage – siehe nativ.py. self.nativ.groesse_ziehen(self._rand) self._rand = "" return "break" diff --git a/provtool/gui/win32.py b/provtool/gui/win32.py index 3119a65..fb246c6 100644 --- a/provtool/gui/win32.py +++ b/provtool/gui/win32.py @@ -58,6 +58,8 @@ def user32(): u.ReleaseCapture.restype = wintypes.BOOL u.SendMessageW.argtypes = [wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM] u.SendMessageW.restype = wintypes.LPARAM + u.PostMessageW.argtypes = [wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM] + u.PostMessageW.restype = wintypes.BOOL u.ShowWindow.argtypes = [wintypes.HWND, ctypes.c_int] u.ShowWindow.restype = wintypes.BOOL u.IsIconic.argtypes = [wintypes.HWND] @@ -127,7 +129,9 @@ def nativ_verschieben(fenster: tk.Misc) -> bool: if not h: return False user32().ReleaseCapture() - user32().SendMessageW(h, 0x00A1, 2, 0) # WM_NCLBUTTONDOWN, HTCAPTION + # PostMessage statt SendMessage: Die Verschiebe-Schleife von Windows darf nicht innerhalb eines + # Tk-Ereignisses laufen (Tk führt darin Python-Ereignisse aus -> harter Absturz, siehe nativ.py). + user32().PostMessageW(h, 0x00A1, 2, 0) # WM_NCLBUTTONDOWN, HTCAPTION return True diff --git a/tests/test_gui.py b/tests/test_gui.py index 7d3312b..f436ed4 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -836,13 +836,8 @@ def test_rand_zeiger_wird_zurueckgesetzt(lauf_mit_daten): lauf.ablauf([]) # erst wenn das Hauptfenster zu sehen ist (Größen stimmen dann) ev = type("E", (), {"x": 2, "y": lauf.root.winfo_height() // 2})() r._rand_cursor(ev) - if r.nativ_an: - # Windows: den Zeiger setzt Windows selbst (WM_SETCURSOR am äußeren Fenster, siehe - # test_windows_echtes_fenster_andocken_und_rand) – Tk darf keinen eigenen setzen, der hängen bleibt. - assert r.aussen.cget("cursor") == "", "Tk-Zeiger trotz nativem Rahmen gesetzt" - assert r._welcher_rand(2, 10) == "nw" and r._welcher_rand(r.aussen.winfo_width() - 3, 10) == "ne" - lauf.pruefen() - return + # Den Zeiger verwaltet immer Tk – auch mit nativem Windows-Rahmen (gemeldet in 1.3.0: setzte + # Windows ihn am Rand, blieb er im Fenster der Größenpfeil, weil Tk davon nichts wusste). assert r.aussen.cget("cursor"), "am Rand muss der Größenänderungs-Zeiger erscheinen" r.inhalt.event_generate("") lauf.root.update() @@ -923,11 +918,6 @@ def test_windows_echtes_fenster_andocken_und_rand(lauf_mit_daten): m["ecke"] = n.trefferpruefung(n.hwnd, x + bw - 3, y + bh - 3) == nativ.HT["se"] m["ecke_lang"] = n.trefferpruefung(n.hwnd, x + bw - 3, y + 10) == nativ.HT["ne"] m["innen"] = n.trefferpruefung(n.hwnd, x + bw // 2, y + bh // 2) == nativ.HTCLIENT - m["rand_durchlaessig"] = n.trefferpruefung(r.aussen.winfo_id(), x + 2, my) == nativ.HTTRANSPARENT - m["innen_nicht"] = n.trefferpruefung(r.aussen.winfo_id(), x + bw // 2, y + 3 + 30) != nativ.HTTRANSPARENT - # die Maus landet am Rand wirklich beim äußeren Fenster (durch Tk-Fenster hindurch) - m["maus_am_rand"] = n.fenster_bei(x + 2, my) == n.hwnd - m["maus_innen"] = n.fenster_bei(x + bw // 2, y + bh // 2) != n.hwnd # Tk darf das Fenster bei internen Größenanfragen nicht um einen Rahmen vergrößern n.setzen(x, y, bw - 37, bh - 23) # wie Größe ziehen durch den Benutzer @@ -960,8 +950,7 @@ def test_windows_echtes_fenster_andocken_und_rand(lauf_mit_daten): assert all(m["stil"].values()), f"Fensterstil unvollständig: {m['stil']}" assert m["programmfenster"], "Fenster ist für Windows noch ein Werkzeugfenster (kein Andocken/Win+Pfeil)" assert m["rahmen_unsichtbar"] and m["tk_gleich_aussen"], "Windows-Rahmen ist sichtbar / Tk-Größe passt nicht" - for k in ("links", "ecke", "ecke_lang", "innen", "rand_durchlaessig", "innen_nicht", "maus_am_rand", - "maus_innen"): + for k in ("links", "ecke", "ecke_lang", "innen"): assert m[k], f"Trefferprüfung am Rand falsch: {k} ({m})" assert m["waechst_nicht"][0] == m["waechst_nicht"][1], f"Fenster ist gewachsen: {m['waechst_nicht']}" assert m["max"] == (True, True), f"Maximieren über Windows nicht erkannt: {m['max']}" @@ -1046,3 +1035,20 @@ def test_export_dialog_standard_eine_datei_ohne_null(lauf_mit_daten, tmp_path): zeilen = [r for r in openpyxl.load_workbook(dateien[0]).active.iter_rows(values_only=True) if r[1]] assert [r[1] for r in zeilen] == ["Sonderbonus Aktion"], "0-€-Position wurde exportiert" lauf.pruefen() + + +def test_rand_druecken_startet_natives_ziehen(lauf_mit_daten, monkeypatch): + """Mit nativem Rahmen zieht Windows die Größe – angestoßen beim Drücken auf den Rand.""" + lauf = lauf_mit_daten + r = lauf.app.rahmen + if not r.nativ_an: + pytest.skip("nur mit nativem Windows-Rahmen") + lauf.ablauf([]) + gezogen = [] + monkeypatch.setattr(r.nativ, "groesse_ziehen", gezogen.append) + ev = type("E", (), {"x": 2, "y": lauf.root.winfo_height() // 2, "x_root": 0, "y_root": 0})() + assert r._resize_start(ev) == "break" + ev.x, ev.y = r.aussen.winfo_width() - 2, r.aussen.winfo_height() - 2 + r._resize_start(ev) + assert gezogen == ["w", "se"] + lauf.pruefen() diff --git a/tests/test_kern.py b/tests/test_kern.py index 909ad4b..813730d 100644 --- a/tests/test_kern.py +++ b/tests/test_kern.py @@ -165,8 +165,16 @@ def test_zuordnung_regeln_und_export(db, freenet_csv, vodafone_xlsx, tmp_path): # Standard: freenet UND Vodafone in einer Datei (gemeldet: Vodafone fehlte im Export) eine = export.exportiere_haendler(tmp_path / "out1", "Händler A", eigene, "Oktober 2026") assert [d.name for d in eine] == ["Händler A - Oktober 2026.xlsx"] - arten = [r[1] for r in openpyxl.load_workbook(eine[0]).active.iter_rows(values_only=True) if r[1]] + zeilen1 = [r for r in openpyxl.load_workbook(eine[0]).active.iter_rows(values_only=True) if r[1]] + arten = [r[1] for r in zeilen1] assert "Freischaltungsvergütung Vodafone" in arten and "SIM-Karten" in arten + # Spalte A: Vodafone mit Aktivierungsdatum, freenet mit Zeitraum + vf = next(r for r in zeilen1 if r[1] == "Freischaltungsvergütung Vodafone") + assert vf[0] == datetime(2026, 9, 8) + assert next(r for r in zeilen1 if r[1] == "SIM-Karten")[0] == "2026.10.01-2026.10.07" + ws1 = openpyxl.load_workbook(eine[0]).active + zeile_vf = next(c.row for c in ws1["B"] if c.value == "Freischaltungsvergütung Vodafone") + assert ws1.cell(zeile_vf, 1).number_format == "DD.MM.YYYY" ws = openpyxl.load_workbook(dateien[0]).active zeilen = [[c.value for c in r] for r in ws.iter_rows(min_row=1, max_row=4)] # Sortierung Zeitraum → Provisionsart → Datum