dynamic-test-engine 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,354 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import subprocess
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ import numpy as np
9
+
10
+ from automation import config
11
+ from automation.utils.device_interface import DeviceInterface
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class WindowsHost(DeviceInterface):
17
+ """DeviceInterface-Implementierung für Windows Desktop über pywinauto und PIL."""
18
+
19
+ def __init__(self) -> None:
20
+ self._process_name: str | None = None
21
+ self._pid: int | None = None
22
+ self._app = None # pywinauto.Application
23
+
24
+ def connect(self, target: str | None = None, process_name: str | None = None) -> None:
25
+ """Verbindet mit einem laufenden Windows-Prozess per PID, Prozessname oder Fenstertitel.
26
+ Einziger Ort, der das pywinauto.Application-Handle erzeugt."""
27
+ from pywinauto import Application # lazy: nur auf Windows installierbar
28
+
29
+ if target is None:
30
+ raise ValueError("WindowsHost.connect erwartet Prozessname, PID oder Fenstertitel.")
31
+
32
+ # DPI-Awareness setzen, damit Screenshot-Koordinaten und Tap-Koordinaten übereinstimmen.
33
+ # Bei Display-Scaling (z. B. 150%) weichen PIL-Pixel und pywinauto-Coords sonst ab.
34
+ # Muss auf Hyper-V Windows 11 verifiziert werden.
35
+ try:
36
+ import ctypes
37
+ ctypes.windll.shcore.SetProcessDpiAwareness(2)
38
+ except Exception:
39
+ try:
40
+ import ctypes
41
+ ctypes.windll.user32.SetProcessDPIAware()
42
+ except Exception:
43
+ pass
44
+
45
+ # PID direkt angegeben
46
+ try:
47
+ pid = int(target)
48
+ try:
49
+ self._app = Application(backend="uia").connect(process=pid)
50
+ except Exception as e:
51
+ raise RuntimeError(f"Prozess mit PID '{pid}' nicht erreichbar: {e}") from e
52
+ self._pid = pid
53
+ self._process_name = process_name or target
54
+ return
55
+ except ValueError:
56
+ pass
57
+
58
+ # Prozessname (*.exe) → PID via tasklist (Windows Built-in)
59
+ if target.lower().endswith(".exe"):
60
+ result = subprocess.run(
61
+ ["tasklist", "/fi", f"IMAGENAME eq {target}", "/fo", "csv", "/nh"],
62
+ capture_output=True,
63
+ encoding="oem",
64
+ errors="replace",
65
+ )
66
+ matches = [ln for ln in result.stdout.strip().splitlines() if target.lower() in ln.lower()]
67
+ if not matches:
68
+ raise RuntimeError(f"Prozess '{target}' nicht gefunden.")
69
+ # CSV-Spalten: "Name","PID","Session","SessionNr","Speicher"
70
+ pid = int(matches[0].split('","')[1])
71
+ try:
72
+ self._app = Application(backend="uia").connect(process=pid)
73
+ except Exception as e:
74
+ raise RuntimeError(f"Prozess '{target}' (PID {pid}) nicht erreichbar: {e}") from e
75
+ self._pid = pid
76
+ self._process_name = process_name or target
77
+ return
78
+
79
+ # Fenstertitel → best_match
80
+ try:
81
+ self._app = Application(backend="uia").connect(best_match=target)
82
+ except Exception as e:
83
+ raise RuntimeError(f"Fenster mit Titel-Match '{target}' nicht gefunden: {e}") from e
84
+ self._process_name = process_name or target
85
+ try:
86
+ self._pid = self._app.top_window().process_id()
87
+ except Exception:
88
+ self._pid = None
89
+
90
+ def close(self) -> None:
91
+ """Beendet die verbundene App PID-scoped statt per Exe-Name, damit andere laufende
92
+ Instanzen desselben Exe-Namens unangetastet bleiben."""
93
+ if self._pid is None:
94
+ raise RuntimeError("WindowsHost.close: keine verbundene App (keine PID bekannt).")
95
+ subprocess.run(["taskkill", "/f", "/pid", str(self._pid)], capture_output=True)
96
+ self._app = None
97
+ self._pid = None
98
+ self._process_name = None
99
+
100
+ @property
101
+ def app(self):
102
+ """Das verbundene pywinauto.Application-Handle, None wenn (noch) nicht verbunden.
103
+ Einzige Stelle, über die WindowsUiaEngine auf die Verbindung zugreift."""
104
+ return self._app
105
+
106
+ def window(self):
107
+ """Kanonische Fensterauswahl für UIA und Vision: das flächengrößte sichtbare
108
+ Top-Level-Fenster der verbundenen App, robuster als das Z-Order-basierte top_window()."""
109
+ windows = self._app.windows(visible_only=True)
110
+ if not windows:
111
+ return self._app.top_window()
112
+
113
+ return max(windows, key=lambda w: _rect_area(w.rectangle()))
114
+
115
+ def _window_rect(self) -> tuple[int, int, int, int]:
116
+ """Fensterrechteck des per window() gewählten App-Fensters in Desktop-Koordinaten.
117
+ Loggt nur eine Warnung, wenn das Fenster außerhalb des Primärmonitors liegt."""
118
+ rect = self.window().rectangle()
119
+ left, top, right, bottom = rect.left, rect.top, rect.right, rect.bottom
120
+
121
+ import ctypes
122
+ user32 = ctypes.windll.user32
123
+ primary_w = user32.GetSystemMetrics(0)
124
+ primary_h = user32.GetSystemMetrics(1)
125
+ if left < 0 or top < 0 or right > primary_w or bottom > primary_h:
126
+ logger.warning(
127
+ "WindowsHost: App-Fenster (%s, %s, %s, %s) liegt außerhalb des Primärmonitors "
128
+ "(%sx%s). Screenshot/Koordinaten-Mapping sind nur für den Primärmonitor verifiziert.",
129
+ left, top, right, bottom, primary_w, primary_h,
130
+ )
131
+ return left, top, right, bottom
132
+
133
+ def _to_desktop(self, x: int, y: int) -> tuple[int, int]:
134
+ """Rechnet Fenster-relative Koordinaten auf absolute Desktop-Koordinaten zurück, ohne
135
+ Verbindung unverändert (Vollbild-Fallback). Live-Lookup pro Aufruf, kein Caching."""
136
+ if self._app is None:
137
+ return x, y
138
+ try:
139
+ left, top, _, _ = self._window_rect()
140
+ except Exception as e:
141
+ raise RuntimeError(f"Fensterrechteck für Koordinaten-Mapping nicht ermittelbar: {e}") from e
142
+ return x + left, y + top
143
+
144
+ def screenshot(self) -> np.ndarray:
145
+ """Screenshot via PIL.ImageGrab, gecroppt auf das verbundene App-Fenster. Ohne Verbindung
146
+ oder bei nicht ermittelbarem Fensterrechteck: Vollbild-Fallback."""
147
+ import cv2
148
+ import numpy as np
149
+ from PIL import ImageGrab # lazy: kein Display auf Linux-Devcontainer
150
+
151
+ bbox = None
152
+ if self._app is not None:
153
+ try:
154
+ bbox = self._window_rect()
155
+ except Exception:
156
+ bbox = None
157
+
158
+ img = ImageGrab.grab(bbox=bbox, all_screens=True)
159
+ arr = np.array(img)
160
+ return cv2.cvtColor(arr, cv2.COLOR_RGB2BGR)
161
+
162
+ def tap(self, x: int, y: int) -> None:
163
+ """Linksklick an Fenster-relativen Koordinaten (Ursprung des Screenshots), intern über
164
+ _to_desktop() auf absolute Bildschirmkoordinaten zurückgerechnet."""
165
+ from pywinauto import mouse # lazy: Windows-only
166
+
167
+ dx, dy = self._to_desktop(x, y)
168
+ mouse.click(button="left", coords=(dx, dy))
169
+
170
+ def move_mouse(self, x: int, y: int) -> None:
171
+ """Bewegt den Mauszeiger an Fenster-relative Koordinaten (Ursprung des Screenshots),
172
+ intern über _to_desktop() zurückgerechnet, ohne zu klicken."""
173
+ from pywinauto import mouse # lazy: Windows-only
174
+
175
+ dx, dy = self._to_desktop(x, y)
176
+ mouse.move(coords=(dx, dy))
177
+
178
+ def type_text(self, text: str) -> None:
179
+ """Tippt literalen Text in das fokussierte Element via pywinauto. Sonderzeichen der
180
+ send_keys-Syntax werden vor dem Senden escaped, damit sie als Literaltext ankommen."""
181
+ from pywinauto.keyboard import send_keys # lazy: Windows-only
182
+
183
+ send_keys(_escape_send_keys(text), with_spaces=True)
184
+
185
+ def focused_process_id(self) -> int | None:
186
+ """PID des Fensters mit aktuellem Eingabefokus, None ohne Vordergrundfenster.
187
+ restype/argtypes explizit gesetzt, sonst schneidet ctypes das HWND auf 64-Bit ab."""
188
+ import ctypes
189
+
190
+ user32 = ctypes.windll.user32
191
+ user32.GetForegroundWindow.restype = ctypes.c_void_p
192
+ user32.GetWindowThreadProcessId.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_ulong)]
193
+
194
+ hwnd = user32.GetForegroundWindow()
195
+ if not hwnd:
196
+ return None
197
+ pid = ctypes.c_ulong()
198
+ user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
199
+ return pid.value or None
200
+
201
+ def verify_focus_in_target(self) -> bool:
202
+ """Prüft, ob der Eingabefokus im verbundenen Zielprozess liegt. Ohne Verbindung oder
203
+ bei technisch nicht ermittelbarem Foreground-Window bleibt die Rückgabe tolerant (True)."""
204
+ if self._app is None or self._pid is None:
205
+ return True
206
+ foreground_pid = self.focused_process_id()
207
+ if foreground_pid is None:
208
+ return True
209
+ return foreground_pid == self._pid
210
+
211
+ def active_region(self) -> tuple[int, int, int, int] | None:
212
+ """Ausdehnung des aktuell obersten (Z-Order) App-Fensters in Screenshot-relativen
213
+ Koordinaten, None wenn kein Overlay über dem Hauptfenster liegt oder nicht verbunden."""
214
+ if self._app is None:
215
+ return None
216
+ try:
217
+ main = self.window()
218
+ top = self._app.top_window()
219
+ except Exception:
220
+ return None
221
+ if top.handle == main.handle:
222
+ return None
223
+ main_rect = main.rectangle()
224
+ top_rect = top.rectangle()
225
+ return (
226
+ top_rect.left - main_rect.left,
227
+ top_rect.top - main_rect.top,
228
+ top_rect.right - main_rect.left,
229
+ top_rect.bottom - main_rect.top,
230
+ )
231
+
232
+ def value_region(self, text: str) -> tuple[int, int, int, int] | None:
233
+ """Ausdehnung des einen Edit-Elements im Hauptfenster, dessen aktueller Wert exakt
234
+ text entspricht, in window()-relativen Koordinaten. None bei 0/>1 Kandidaten."""
235
+ if self._app is None:
236
+ return None
237
+ try:
238
+ main = self.window()
239
+ edits = main.descendants(control_type="Edit", cache_enable=True)
240
+ matches = []
241
+ for el in edits:
242
+ try:
243
+ value = el.get_value()
244
+ except Exception:
245
+ value = el.window_text()
246
+ if value.strip() == text.strip():
247
+ matches.append(el)
248
+ except Exception:
249
+ return None
250
+ if len(matches) != 1:
251
+ return None
252
+ main_rect = main.rectangle()
253
+ rect = matches[0].rectangle()
254
+ return (
255
+ rect.left - main_rect.left,
256
+ rect.top - main_rect.top,
257
+ rect.right - main_rect.left,
258
+ rect.bottom - main_rect.top,
259
+ )
260
+
261
+ def clear_focused_field(self, x: int, y: int) -> None:
262
+ """Leert das Feld an (x, y) und prüft anschließend, ob es wirklich leer ist."""
263
+ from pywinauto.keyboard import send_keys # lazy: Windows-only
264
+ import time
265
+
266
+ send_keys("{HOME}+{END}{DELETE}", with_spaces=True)
267
+ time.sleep(config.WINDOWS_CLEAR_FIELD_DELAY)
268
+
269
+ dx, dy = self._to_desktop(x, y)
270
+ main = self.window()
271
+ candidates = [
272
+ el for el in main.descendants(control_type="Edit", cache_enable=True)
273
+ if _rect_contains(el.rectangle(), dx, dy)
274
+ ]
275
+ if not candidates:
276
+ raise RuntimeError(f"Kein Edit-Element an Position ({x}, {y}) für Lösch-Verifikation.")
277
+ el = min(candidates, key=lambda c: _rect_area(c.rectangle()))
278
+ try:
279
+ value = el.get_value()
280
+ except Exception:
281
+ value = el.window_text()
282
+ if value.strip():
283
+ raise RuntimeError(f"Feld an ({x}, {y}) nach Löschversuch nicht leer: '{value}'.")
284
+
285
+ def scroll(self, x: int, y: int, amount: int, duration_ms: int | None = None) -> None:
286
+ """Mausrad-Scroll an Fenster-relativen Koordinaten (x, y). Skaliert über eine Schleife
287
+ aus Einzel-Notches, nicht über einen großen wheel_dist-Wert."""
288
+ from pywinauto import mouse # lazy: Windows-only
289
+ import time
290
+
291
+ dx, dy = self._to_desktop(x, y)
292
+ direction = -1 if amount > 0 else 1
293
+ for _ in range(config.WINDOWS_SCROLL_NOTCHES):
294
+ mouse.scroll(coords=(dx, dy), wheel_dist=direction)
295
+ time.sleep(config.WINDOWS_SCROLL_EVENT_DELAY)
296
+
297
+ def screen_key(self) -> tuple[str, str, str, str] | None:
298
+ """Stabile Screen-Identität: Prozessname, Fenstertitel, Exe-Version, Auflösung.
299
+ None bei Fehler → Cache wird übersprungen."""
300
+ try:
301
+ if self._app is None or self._process_name is None:
302
+ return None
303
+
304
+ title = self._app.top_window().window_text()
305
+ version = _exe_version(self._pid)
306
+
307
+ import ctypes
308
+ user32 = ctypes.windll.user32
309
+ w = user32.GetSystemMetrics(0)
310
+ h = user32.GetSystemMetrics(1)
311
+ resolution = f"{w}x{h}"
312
+
313
+ return self._process_name, title, version, resolution
314
+ except Exception:
315
+ return None
316
+
317
+
318
+ def _rect_contains(rect, x: int, y: int) -> bool:
319
+ """Prüft, ob (x, y) innerhalb von rect liegt."""
320
+ return rect.left <= x <= rect.right and rect.top <= y <= rect.bottom
321
+
322
+
323
+ def _rect_area(rect) -> int:
324
+ """Fläche von rect."""
325
+ return max(0, rect.right - rect.left) * max(0, rect.bottom - rect.top)
326
+
327
+
328
+ _SEND_KEYS_SPECIAL_CHARS = "+^%~(){}"
329
+
330
+
331
+ def _escape_send_keys(text: str) -> str:
332
+ """Escaped die Sonderzeichen der pywinauto-send_keys-Syntax als Literal. Zeichenweise über
333
+ den Original-Text, nicht per verketteten str.replace(), sonst doppeltes Escaping."""
334
+ return "".join(f"{{{ch}}}" if ch in _SEND_KEYS_SPECIAL_CHARS else ch for ch in text)
335
+
336
+
337
+ def _exe_version(pid: int | None) -> str:
338
+ """Exe-Versionsnummer eines Prozesses via PowerShell. Leerstring bei Fehler."""
339
+ if pid is None:
340
+ return ""
341
+ try:
342
+ script = (
343
+ f"$p = Get-Process -Id {pid} -ErrorAction SilentlyContinue; "
344
+ f"if ($p) {{ $p.MainModule.FileVersionInfo.FileVersion }} else {{ '' }}"
345
+ )
346
+ result = subprocess.run(
347
+ ["powershell", "-NoProfile", "-Command", script],
348
+ capture_output=True,
349
+ text=True,
350
+ timeout=5,
351
+ )
352
+ return result.stdout.strip()
353
+ except Exception:
354
+ return ""