Absturz beim Ziehen des Fensters behoben, Zeiger am Rand wieder über Tk, Vodafone-Datum in Spalte A

- Verschieben/Größe ziehen per PostMessage statt SendMessage: Die Windows-Verschiebeschleife lief
  innerhalb eines Tk-Ereignisses; Tk führte darin Python-Ereignisse (<Configure>) aus -> harter
  Absturz „PyEval_RestoreThread … thread state is NULL“ (normal und aus maximiert).
- Größen-Mauszeiger am Rand wieder von Tk (blieb sonst im Fenster stehen); Windows zieht beim Drücken.
- Neuer Maustest im Build (xdotool, echte Maus): Titelleiste/Rand ziehen, maximieren, herausziehen.
- Export: Bei Vodafone steht in Spalte A das Aktivierungsdatum (statt Abrechnungslauf).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kerim AtilganandClaude Opus 5.5 committed 2026-11-02 18:03:33 +01:00
1 parent 0880ab2d42
commit 97c73b82c7
11 files changed
+165 -70

No files matched your search

+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
- name: Werkzeuge für Checkout/Upload installieren - name: Werkzeuge für Checkout/Upload installieren
run: | run: |
apt-get update -qq 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 - uses: actions/checkout@v4
+3
View File
@@ -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 # -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 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 ==" echo "== Version =="
VERSION=$($PY build/version_info.py | tr -d '\r') VERSION=$($PY build/version_info.py | tr -d '\r')
echo "Version $VERSION" echo "Version $VERSION"
+36
View File
@@ -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()
+52
View File
@@ -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"
+1 -1
View File
@@ -1,5 +1,5 @@
"""Provisions-Manager 95 – Provisionsabrechnung für Unterhändler der Handy-Insel Miesbach.""" """Provisions-Manager 95 – Provisionsabrechnung für Unterhändler der Handy-Insel Miesbach."""
__version__ = "1.3.0" __version__ = "1.3.1"
APP_NAME = "Provisions-Manager 95" APP_NAME = "Provisions-Manager 95"
APP_ORG = "Handy-Insel Miesbach" APP_ORG = "Handy-Insel Miesbach"
+11 -1
View File
@@ -45,6 +45,14 @@ def dateiname(text: str) -> str:
AIRTIME_TEXT = "Airtime" 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: def airtime_zeile(airtime: list[dict]) -> dict:
"""Eine Zeile „Airtime = Summe“ für die Abrechnung.""" """Eine Zeile „Airtime = Summe“ für die Abrechnung."""
zeitraeume = sorted({p.get("zeitraum") or "" for p in airtime} - {""}) 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 erste = zeile
for p in sortiert(positionen) + list(schluss or []): for p in sortiert(positionen) + list(schluss or []):
werte = [ werte = [
p.get("zeitraum") or None, spalte_a(p),
p.get("provisionsart") or None, p.get("provisionsart") or None,
_zahl_oder_text(p.get("kennung")), _zahl_oder_text(p.get("kennung")),
date.fromisoformat(p["datum"]) if p.get("datum") else None, 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 = ws.cell(zeile, c, v)
cell.font = bold if p.get("_summe") and c in (2, 11) else font cell.font = bold if p.get("_summe") and c in (2, 11) else font
ws.cell(zeile, 4).number_format = "DD.MM.YYYY" 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, 5).number_format = "0"
ws.cell(zeile, 6).number_format = "0" ws.cell(zeile, 6).number_format = "0"
ws.cell(zeile, 11).number_format = EURO_FORMAT ws.cell(zeile, 11).number_format = EURO_FORMAT
+21 -47
View File
@@ -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: Größenanpassung um die Rahmenbreite wachsen). Windows erledigt alles Weitere selbst:
- Ziehen an der Titelleiste (WM_NCLBUTTONDOWN/HTCAPTION) mit Andocken links/rechts/oben (Maximieren) - Ziehen an der Titelleiste (WM_NCLBUTTONDOWN/HTCAPTION) mit Andocken links/rechts/oben (Maximieren)
- Win+Pfeiltasten, Doppelklick, Systemmenü (Alt+Leertaste), Taskleiste, Alt+Tab - 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 - Größe ändern: Den Größen-Mauszeiger am Rand zeigt Tk (rahmen.py) – so weiß Tk immer, welcher Zeiger
passenden Mauszeiger und zieht die Größe selbst (flüssig, kein hängender 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 -> WICHTIG:
- Die Fensterprozeduren hier laufen mitten in Tk. Sie dürfen Tk NICHT aufrufen (Tk-Sperre ->
Verklemmung), nur Win32-Funktionen. 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. <Configure>) 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 from __future__ import annotations
@@ -27,15 +33,13 @@ from ctypes import wintypes
IS_WIN = sys.platform.startswith("win") IS_WIN = sys.platform.startswith("win")
WM_NCDESTROY, WM_NCCALCSIZE, WM_NCHITTEST, WM_NCACTIVATE = 0x0082, 0x0083, 0x0084, 0x0086 WM_NCDESTROY, WM_NCCALCSIZE, WM_NCHITTEST, WM_NCACTIVATE = 0x0082, 0x0083, 0x0084, 0x0086
WM_NCLBUTTONDOWN, WM_SYSCOMMAND, WM_SETCURSOR = 0x00A1, 0x0112, 0x0020 WM_NCLBUTTONDOWN, WM_SYSCOMMAND = 0x00A1, 0x0112
HTCLIENT, HTCAPTION, HTTRANSPARENT = 1, 2, -1 HTCLIENT, HTCAPTION = 1, 2
HT = {"w": 10, "e": 11, "n": 12, "nw": 13, "ne": 14, "s": 15, "sw": 16, "se": 17} HT = {"w": 10, "e": 11, "n": 12, "nw": 13, "ne": 14, "s": 15, "sw": 16, "se": 17}
GWL_STYLE, GWL_EXSTYLE = -16, -20 GWL_STYLE, GWL_EXSTYLE = -16, -20
WS_CAPTION, WS_THICKFRAME, WS_SYSMENU = 0x00C00000, 0x00040000, 0x00080000 WS_CAPTION, WS_THICKFRAME, WS_SYSMENU = 0x00C00000, 0x00040000, 0x00080000
WS_MINIMIZEBOX, WS_MAXIMIZEBOX = 0x00020000, 0x00010000 WS_MINIMIZEBOX, WS_MAXIMIZEBOX = 0x00020000, 0x00010000
WS_EX_TOOLWINDOW, WS_EX_APPWINDOW = 0x00000080, 0x00040000 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 SWP_NOSIZE, SWP_NOMOVE, SWP_NOZORDER, SWP_NOACTIVATE, SWP_FRAMECHANGED = 0x1, 0x2, 0x4, 0x10, 0x20
SC_MAXIMIZE, SC_RESTORE, SC_MINIMIZE = 0xF030, 0xF120, 0xF020 SC_MAXIMIZE, SC_RESTORE, SC_MINIMIZE = 0xF030, 0xF120, 0xF020
@@ -73,9 +77,7 @@ def _api():
("GetMonitorInfoW", [wintypes.HMONITOR, ctypes.POINTER(MONITORINFO)], wintypes.BOOL), ("GetMonitorInfoW", [wintypes.HMONITOR, ctypes.POINTER(MONITORINFO)], wintypes.BOOL),
("ReleaseCapture", [], wintypes.BOOL), ("SendMessageW", [H, U, W, L], LRESULT), ("ReleaseCapture", [], wintypes.BOOL), ("SendMessageW", [H, U, W, L], LRESULT),
("PostMessageW", [H, U, W, L], wintypes.BOOL), ("ShowWindow", [H, ctypes.c_int], wintypes.BOOL), ("PostMessageW", [H, U, W, L], wintypes.BOOL), ("ShowWindow", [H, ctypes.c_int], wintypes.BOOL),
("WindowFromPoint", [wintypes.POINT], H), ("WindowFromPoint", [wintypes.POINT], H)):
("LoadCursorW", [wintypes.HINSTANCE, ctypes.c_void_p], wintypes.HANDLE),
("SetCursor", [wintypes.HANDLE], wintypes.HANDLE)):
f = getattr(u, name) f = getattr(u, name)
f.argtypes, f.restype = args, res f.argtypes, f.restype = args, res
c.SetWindowSubclass.argtypes = [H, SUBCLASSPROC, ctypes.c_size_t, ctypes.c_size_t] 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: class NativerRahmen:
"""Macht das Tk-Hauptfenster zu einem echten Windows-Fenster ohne sichtbaren Windows-Rahmen. """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).
"""
def __init__(self, root: tk.Tk, rand: int, ecke: int): def __init__(self, root: tk.Tk, rand: int, ecke: int):
self.root = root self.root = root
@@ -121,11 +119,10 @@ class NativerRahmen:
self.hwnd = None self.hwnd = None
self.aktiv = False self.aktiv = False
self._procs: dict[int, object] = {} # Referenzen halten, sonst räumt Python die Callbacks weg self._procs: dict[int, object] = {} # Referenzen halten, sonst räumt Python die Callbacks weg
self._rand_hwnds: set[int] = set()
# ------------------------------------------------------------------ Einrichten # ------------------------------------------------------------------ Einrichten
def einrichten(self, rand_widgets: list[tk.Misc]) -> bool: def einrichten(self) -> bool:
"""Nach dem ersten Anzeigen aufrufen (vorher gibt es das äußere Windows-Fenster noch nicht).""" """Sobald Tk das äußere Windows-Fenster angelegt hat (möglichst vor dem ersten Anzeigen)."""
if self.aktiv: if self.aktiv:
return True return True
try: try:
@@ -142,10 +139,6 @@ class NativerRahmen:
u.SetWindowLongW(h, GWL_EXSTYLE, (ex & ~WS_EX_TOOLWINDOW) | WS_EX_APPWINDOW) u.SetWindowLongW(h, GWL_EXSTYLE, (ex & ~WS_EX_TOOLWINDOW) | WS_EX_APPWINDOW)
if not self._unterklasse(h, self._aussen_proc): if not self._unterklasse(h, self._aussen_proc):
return False 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 self.aktiv = True
# Rahmen neu berechnen lassen (jetzt: unsichtbar) # Rahmen neu berechnen lassen (jetzt: unsichtbar)
u.SetWindowPos(h, None, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE 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.""" """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) 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.aktiv = False
self._rand_hwnds.clear() return self.einrichten()
return self.einrichten(rand_widgets)
def _unterklasse(self, h: int, fn) -> bool: def _unterklasse(self, h: int, fn) -> bool:
_, c = _api() _, c = _api()
@@ -229,27 +221,12 @@ class NativerRahmen:
if rand: if rand:
return HT[rand] return HT[rand]
return HTCLIENT 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: if msg == WM_NCDESTROY:
self._entfernen(hwnd) self._entfernen(hwnd)
except Exception: # niemals eine Ausnahme in die Fensterprozedur durchlassen except Exception: # niemals eine Ausnahme in die Fensterprozedur durchlassen
pass pass
return c.DefSubclassProc(hwnd, msg, wparam, lparam) 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): def _entfernen(self, hwnd):
proc = self._procs.pop(hwnd, None) proc = self._procs.pop(hwnd, None)
if proc is not None: if proc is not None:
@@ -262,15 +239,16 @@ class NativerRahmen:
# ------------------------------------------------------------------ Bedienung # ------------------------------------------------------------------ Bedienung
def verschieben(self) -> None: 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, _ = _api()
u.ReleaseCapture() 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: def groesse_ziehen(self, rand: str) -> None:
u, _ = _api() u, _ = _api()
u.ReleaseCapture() u.ReleaseCapture()
u.SendMessageW(self.hwnd, WM_NCLBUTTONDOWN, HT[rand], 0) u.PostMessageW(self.hwnd, WM_NCLBUTTONDOWN, HT[rand], 0)
def maximiert(self) -> bool: def maximiert(self) -> bool:
return bool(self.hwnd and _api()[0].IsZoomed(self.hwnd)) return bool(self.hwnd and _api()[0].IsZoomed(self.hwnd))
@@ -298,10 +276,6 @@ class NativerRahmen:
u, _ = _api() u, _ = _api()
return u.GetWindowLongW(self.hwnd, GWL_STYLE), u.GetWindowLongW(self.hwnd, GWL_EXSTYLE) 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: def trefferpruefung(self, hwnd: int, x: int, y: int) -> int:
"""Für Tests: Was meldet das Fenster `hwnd` für den Bildschirmpunkt (WM_NCHITTEST)?""" """Für Tests: Was meldet das Fenster `hwnd` für den Bildschirmpunkt (WM_NCHITTEST)?"""
lp = ((y & 0xFFFF) << 16) | (x & 0xFFFF) lp = ((y & 0xFFFF) << 16) | (x & 0xFFFF)
+6 -4
View File
@@ -290,9 +290,9 @@ class Hauptrahmen:
except tk.TclError: except tk.TclError:
sichtbar = True sichtbar = True
if self.nativ.veraltet(): # Tk hat das äußere Fenster neu angelegt 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: else:
ok = self.nativ.einrichten([self.root, self.aussen]) ok = self.nativ.einrichten()
if ok: if ok:
# Erst eingerichtet, als das Fenster schon zu sehen war -> Taskleiste muss es neu erfassen # Erst eingerichtet, als das Fenster schon zu sehen war -> Taskleiste muss es neu erfassen
self._taskleiste_neu = sichtbar self._taskleiste_neu = sichtbar
@@ -502,7 +502,8 @@ class Hauptrahmen:
"sw": "bottom_left_corner"} "sw": "bottom_left_corner"}
def _rand_cursor(self, e): 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 return
rand = self._welcher_rand(e.x, e.y) rand = self._welcher_rand(e.x, e.y)
zeiger = (self.CURSOR_WIN if IS_WIN else self.CURSOR_X11).get(rand, "") 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): def _resize_start(self, e, rand: str | None = None):
self._rand = rand or self._welcher_rand(e.x, e.y) self._rand = rand or self._welcher_rand(e.x, e.y)
if self._rand and self.nativ_an and not self.maximiert: 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.nativ.groesse_ziehen(self._rand)
self._rand = "" self._rand = ""
return "break" return "break"
+5 -1
View File
@@ -58,6 +58,8 @@ def user32():
u.ReleaseCapture.restype = wintypes.BOOL u.ReleaseCapture.restype = wintypes.BOOL
u.SendMessageW.argtypes = [wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM] u.SendMessageW.argtypes = [wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM]
u.SendMessageW.restype = 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.argtypes = [wintypes.HWND, ctypes.c_int]
u.ShowWindow.restype = wintypes.BOOL u.ShowWindow.restype = wintypes.BOOL
u.IsIconic.argtypes = [wintypes.HWND] u.IsIconic.argtypes = [wintypes.HWND]
@@ -127,7 +129,9 @@ def nativ_verschieben(fenster: tk.Misc) -> bool:
if not h: if not h:
return False return False
user32().ReleaseCapture() 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 return True
+20 -14
View File
@@ -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) lauf.ablauf([]) # erst wenn das Hauptfenster zu sehen ist (Größen stimmen dann)
ev = type("E", (), {"x": 2, "y": lauf.root.winfo_height() // 2})() ev = type("E", (), {"x": 2, "y": lauf.root.winfo_height() // 2})()
r._rand_cursor(ev) r._rand_cursor(ev)
if r.nativ_an: # Den Zeiger verwaltet immer Tk – auch mit nativem Windows-Rahmen (gemeldet in 1.3.0: setzte
# Windows: den Zeiger setzt Windows selbst (WM_SETCURSOR am äußeren Fenster, siehe # Windows ihn am Rand, blieb er im Fenster der Größenpfeil, weil Tk davon nichts wusste).
# 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
assert r.aussen.cget("cursor"), "am Rand muss der Größenänderungs-Zeiger erscheinen" assert r.aussen.cget("cursor"), "am Rand muss der Größenänderungs-Zeiger erscheinen"
r.inhalt.event_generate("<Enter>") r.inhalt.event_generate("<Enter>")
lauf.root.update() 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"] = 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["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["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 # 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 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 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["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" 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", for k in ("links", "ecke", "ecke_lang", "innen"):
"maus_innen"):
assert m[k], f"Trefferprüfung am Rand falsch: {k} ({m})" 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["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']}" 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]] 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" assert [r[1] for r in zeilen] == ["Sonderbonus Aktion"], "0-€-Position wurde exportiert"
lauf.pruefen() 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()
+9 -1
View File
@@ -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) # 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") 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"] 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 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 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)] zeilen = [[c.value for c in r] for r in ws.iter_rows(min_row=1, max_row=4)]
# Sortierung Zeitraum → Provisionsart → Datum # Sortierung Zeitraum → Provisionsart → Datum