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.
- automation/__init__.py +0 -0
- automation/config.py +69 -0
- automation/engines/__init__.py +0 -0
- automation/engines/engine_manager.py +382 -0
- automation/engines/uia_engine.py +220 -0
- automation/engines/vision_base.py +30 -0
- automation/engines/vision_engine.py +474 -0
- automation/engines/windows_uia_engine.py +347 -0
- automation/keywords/__init__.py +0 -0
- automation/keywords/automation_keywords.py +83 -0
- automation/utils/__init__.py +0 -0
- automation/utils/adb.py +220 -0
- automation/utils/cache.py +135 -0
- automation/utils/device_interface.py +60 -0
- automation/utils/image_processing.py +258 -0
- automation/utils/scroll_centering.py +48 -0
- automation/utils/spatial.py +63 -0
- automation/utils/windows_host.py +354 -0
- dynamic_test_engine-0.1.0.dist-info/METADATA +405 -0
- dynamic_test_engine-0.1.0.dist-info/RECORD +23 -0
- dynamic_test_engine-0.1.0.dist-info/WHEEL +5 -0
- dynamic_test_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
- dynamic_test_engine-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
class VisionEngineBase(ABC):
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
def text_exists(self, text: str, img: "np.ndarray | None" = None) -> bool: ...
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def tap_text(self, text: str) -> None: ...
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def tap_icon_near_text(self, icon_path: str, text: str) -> None: ...
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def scroll_to_text(self, text: str, anchor: "str | None" = None, relation: str = "near") -> bool: ...
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def find_element(self, target: str, anchor: str, relation: str = "near", kind: str = "auto") -> "tuple[int, int]": ...
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def tap_element(self, target: str, anchor: str, relation: str = "near", kind: str = "auto") -> None: ...
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def input_text(self, locator: str, text: str) -> None: ...
|
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
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.engines.vision_base import VisionEngineBase
|
|
12
|
+
from automation.utils import image_processing as ip, scroll_centering, spatial
|
|
13
|
+
from automation.utils.cache import JsonFileCache
|
|
14
|
+
from automation.utils.device_interface import DeviceInterface
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
# Vision-OCR-Cache (Methode: docs/recherche_caching.md). Inaktiv bei VISION_OCR_CACHE=False.
|
|
19
|
+
_cache = JsonFileCache(config.VISION_CACHE_DIR)
|
|
20
|
+
|
|
21
|
+
def _ocr(img) -> list:
|
|
22
|
+
"""Full-Screen-OCR, in Zeilen gruppiert (teuer). Koordinaten stammen aus dem übergebenen Screenshot."""
|
|
23
|
+
return ip.ocr_group_lines(ip.ocr_find_text(img, lang=config.OCR_LANG))
|
|
24
|
+
|
|
25
|
+
class VisionMatchError(RuntimeError):
|
|
26
|
+
def __init__(self, message: str, screenshot: "np.ndarray") -> None:
|
|
27
|
+
super().__init__(message)
|
|
28
|
+
self.screenshot = screenshot
|
|
29
|
+
|
|
30
|
+
class VisionEngine(VisionEngineBase):
|
|
31
|
+
|
|
32
|
+
def __init__(self, device: DeviceInterface) -> None:
|
|
33
|
+
self._device = device
|
|
34
|
+
|
|
35
|
+
def _screen_key(self) -> tuple[str, str, str, str] | None:
|
|
36
|
+
"""Stabile Screen-Identität (package, activity, version, device_fp) für den Cache.
|
|
37
|
+
None bei jedem Fehler -> Cache wird übersprungen, die Aktion läuft live weiter."""
|
|
38
|
+
return self._device.screen_key()
|
|
39
|
+
|
|
40
|
+
@staticmethod
|
|
41
|
+
def _padded_crop(img, box: dict, pad: int) -> tuple["np.ndarray", int, int] | None:
|
|
42
|
+
"""Bbox um `pad` Pixel erweitert aus `img` croppen. Gibt (crop, x0, y0) oder None
|
|
43
|
+
bei leerem Crop; x0/y0 sind der Crop-Ursprung in Vollbild-Koordinaten."""
|
|
44
|
+
y0 = max(0, box["top"] - pad)
|
|
45
|
+
y1 = min(img.shape[0], box["top"] + box["height"] + pad)
|
|
46
|
+
x0 = max(0, box["left"] - pad)
|
|
47
|
+
x1 = min(img.shape[1], box["left"] + box["width"] + pad)
|
|
48
|
+
crop = img[y0:y1, x0:x1]
|
|
49
|
+
if crop.size == 0:
|
|
50
|
+
return None
|
|
51
|
+
return crop, x0, y0
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def _crop_ocr_match(img, box: dict, text: str) -> dict | None:
|
|
55
|
+
"""Crop-OCR um eine gecachte Bbox auf dem Live-Screenshot. Gibt die Treffer-Zeile mit
|
|
56
|
+
Live-Koordinaten (in Vollbild-Koordinaten) zurück oder None."""
|
|
57
|
+
padded = VisionEngine._padded_crop(img, box, config.VISION_CROP_PAD)
|
|
58
|
+
if padded is None:
|
|
59
|
+
return None
|
|
60
|
+
crop, x0, y0 = padded
|
|
61
|
+
for line in ip.ocr_group_lines(ip.ocr_find_text(crop, lang=config.OCR_LANG)):
|
|
62
|
+
if text.lower() in line["text"].lower():
|
|
63
|
+
# Koordinaten zurück in Vollbild-Koordinaten verschieben (Crop-Ursprung addieren).
|
|
64
|
+
return {"text": line["text"],
|
|
65
|
+
"left": line["left"] + x0, "top": line["top"] + y0,
|
|
66
|
+
"width": line["width"], "height": line["height"]}
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
def _locate_text(self, text: str, img) -> dict | None:
|
|
70
|
+
"""Live-Bbox der ersten Zeile, die `text` enthält, sonst None. Cache an: Crop-OCR um die
|
|
71
|
+
gecachte Bbox; bei Miss Full-OCR-Fallback und Layout neu cachen."""
|
|
72
|
+
if not config.VISION_OCR_CACHE:
|
|
73
|
+
return next((h for h in _ocr(img) if text.lower() in h["text"].lower()), None)
|
|
74
|
+
|
|
75
|
+
key = self._screen_key()
|
|
76
|
+
entry = _cache.get(*key) if key is not None else None
|
|
77
|
+
cached = next((ln for ln in entry.get("elements", []) if text.lower() in ln["text"].lower()), None) if entry else None
|
|
78
|
+
live = self._crop_ocr_match(img, cached, text) if cached is not None else None
|
|
79
|
+
if live is not None:
|
|
80
|
+
logger.info("VISION_CACHE event=hit keyword=locate_text target=%r", text)
|
|
81
|
+
return live # Treffer: Full-OCR übersprungen
|
|
82
|
+
if cached is not None:
|
|
83
|
+
logger.info("VISION_CACHE event=verify_fail keyword=locate_text target=%r", text)
|
|
84
|
+
else:
|
|
85
|
+
logger.info("VISION_CACHE event=miss keyword=locate_text target=%r", text)
|
|
86
|
+
|
|
87
|
+
# Miss oder Crop-Verifikation fehlgeschlagen: voller Live-Discovery-Fallback.
|
|
88
|
+
lines = _ocr(img)
|
|
89
|
+
if key is not None:
|
|
90
|
+
try:
|
|
91
|
+
_cache.put(key[0], key[1], key[2], key[3], elements=lines)
|
|
92
|
+
except Exception:
|
|
93
|
+
pass # Cache-Schreibfehler darf die Aktion nie brechen
|
|
94
|
+
return next((h for h in lines if text.lower() in h["text"].lower()), None)
|
|
95
|
+
|
|
96
|
+
def text_exists(self, text: str, img: np.ndarray | None = None) -> bool:
|
|
97
|
+
if img is not None:
|
|
98
|
+
# Zwischen-Screenshot aus dem Scroll-Loop: nicht cachen (gleicher Screen-Key bei
|
|
99
|
+
# wechselndem Inhalt würde den Cache thrashen). Voll live lesen.
|
|
100
|
+
return any(text.lower() in h["text"].lower() for h in _ocr(img))
|
|
101
|
+
img = self._device.screenshot()
|
|
102
|
+
return self._locate_text(text, img) is not None
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def _screen_stable(img1: np.ndarray, img2: np.ndarray) -> bool:
|
|
106
|
+
import numpy as np
|
|
107
|
+
h = img1.shape[0]
|
|
108
|
+
crop1 = img1[h // 10 : 9 * h // 10]
|
|
109
|
+
crop2 = img2[h // 10 : 9 * h // 10]
|
|
110
|
+
return float(np.mean(np.abs(crop1.astype(float) - crop2.astype(float)))) < config.VISION_STABLE_THRESHOLD
|
|
111
|
+
|
|
112
|
+
def tap_text(self, text: str) -> None:
|
|
113
|
+
for attempt in (0, 1):
|
|
114
|
+
img = self._device.screenshot()
|
|
115
|
+
line = self._locate_text(text, img)
|
|
116
|
+
if line is not None:
|
|
117
|
+
cx = line["left"] + line["width"] // 2
|
|
118
|
+
cy = line["top"] + line["height"] // 2
|
|
119
|
+
self._device.tap(cx, cy)
|
|
120
|
+
return
|
|
121
|
+
if attempt == 0:
|
|
122
|
+
# Text nicht gefunden, kurze Pause damit laufende Animationen abklingen können.
|
|
123
|
+
time.sleep(1.0)
|
|
124
|
+
raise VisionMatchError(f"Text not found: '{text}'", screenshot=img)
|
|
125
|
+
|
|
126
|
+
def _resolve_target_hit(self, hits: list[dict], text: str, anchor: str | None, relation: str) -> dict | None:
|
|
127
|
+
"""Treffer für `text` unter den OCR-Zeilen `hits`. Ohne anchor der erste Treffer, mit
|
|
128
|
+
anchor der Treffer, der `relation` zum Anker erfüllt."""
|
|
129
|
+
if anchor is None:
|
|
130
|
+
return next((h for h in hits if text.lower() in h["text"].lower()), None)
|
|
131
|
+
anchor_box = next((h for h in hits if anchor.lower() in h["text"].lower()), None)
|
|
132
|
+
if anchor_box is None:
|
|
133
|
+
return None # Anker in diesem Frame nicht sichtbar -> keine Aussage, kein False-Positive
|
|
134
|
+
candidates = [h for h in hits if text.lower() in h["text"].lower() and h is not anchor_box]
|
|
135
|
+
return spatial.resolve(
|
|
136
|
+
anchor_box, candidates, relation,
|
|
137
|
+
overlap_tol=config.SPATIAL_OVERLAP_TOLERANCE,
|
|
138
|
+
max_distance=config.SPATIAL_NEAR_MAX_DISTANCE,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def _scroll_and_search(self, x: int, center: int, step: int, text: str, direction: int = 1,
|
|
142
|
+
anchor: str | None = None, relation: str = "near") -> bool:
|
|
143
|
+
prev_img: np.ndarray | None = None
|
|
144
|
+
for i in range(config.VISION_SCROLL_MAX):
|
|
145
|
+
self._device.scroll(
|
|
146
|
+
x, center, step if direction == 1 else -step,
|
|
147
|
+
duration_ms=config.VISION_SCROLL_SWIPE_DURATION_MS,
|
|
148
|
+
)
|
|
149
|
+
img = self._device.screenshot()
|
|
150
|
+
hits = _ocr(img)
|
|
151
|
+
|
|
152
|
+
hit = self._resolve_target_hit(hits, text, anchor, relation)
|
|
153
|
+
logger.debug("scroll_and_search iteration=%d direction=%d hit=%s", i, direction, hit is not None)
|
|
154
|
+
if hit is not None:
|
|
155
|
+
logger.info("scroll_and_search event=found target=%r direction=%d iteration=%d", text, direction, i)
|
|
156
|
+
height = center * 2
|
|
157
|
+
el_center_y = hit["top"] + hit["height"] // 2
|
|
158
|
+
scroll_centering.center_in_safe_area(self._device, x, height, el_center_y, max_shift=step)
|
|
159
|
+
return True
|
|
160
|
+
|
|
161
|
+
if anchor is not None:
|
|
162
|
+
# Anker re-lokalisieren, deckt fixe und mitscrollende Anker gleichermaßen ab.
|
|
163
|
+
# Kein Treffer -> x, center bleiben unverändert, kein Rückfall auf Bildschirmmitte.
|
|
164
|
+
anchor_box = next((h for h in hits if anchor.lower() in h["text"].lower()), None)
|
|
165
|
+
if anchor_box is not None:
|
|
166
|
+
x, center = spatial.center(anchor_box)
|
|
167
|
+
|
|
168
|
+
if prev_img is not None and self._screen_stable(prev_img, img):
|
|
169
|
+
logger.info("scroll_and_search event=screen_stable target=%r direction=%d iteration=%d", text, direction, i)
|
|
170
|
+
break
|
|
171
|
+
prev_img = img
|
|
172
|
+
else:
|
|
173
|
+
logger.info("scroll_and_search event=exhausted target=%r direction=%d max=%d", text, direction, config.VISION_SCROLL_MAX)
|
|
174
|
+
return False
|
|
175
|
+
|
|
176
|
+
@staticmethod
|
|
177
|
+
def _resolve_type(target: str, kind: str) -> str:
|
|
178
|
+
# "auto": .png-Pfad => Icon (Template-Match), sonst Text (OCR).
|
|
179
|
+
if kind != "auto":
|
|
180
|
+
return kind
|
|
181
|
+
return "icon" if target.lower().endswith(".png") else "text"
|
|
182
|
+
|
|
183
|
+
@staticmethod
|
|
184
|
+
def _verify_cached_boxes(img, custom_element, cached: list[dict], ew: int, eh: int) -> list[dict]:
|
|
185
|
+
"""Live-Crop-Verifikation gecachter Custom-Element-Boxen. Boxen ohne Live-Treffer fallen raus."""
|
|
186
|
+
verified = []
|
|
187
|
+
for box in cached:
|
|
188
|
+
padded = VisionEngine._padded_crop(img, box, config.VISION_CROP_PAD)
|
|
189
|
+
if padded is None:
|
|
190
|
+
continue
|
|
191
|
+
crop, x0, y0 = padded
|
|
192
|
+
hits = ip.match_custom_element(crop, custom_element, threshold=config.MATCH_THRESHOLD)
|
|
193
|
+
if not hits:
|
|
194
|
+
continue
|
|
195
|
+
cx_crop, cy_crop = hits[0]
|
|
196
|
+
# Koordinaten zurück in Vollbild-Koordinaten verschieben (Crop-Ursprung addieren).
|
|
197
|
+
verified.append({
|
|
198
|
+
"left": x0 + cx_crop - ew // 2,
|
|
199
|
+
"top": y0 + cy_crop - eh // 2,
|
|
200
|
+
"width": ew, "height": eh,
|
|
201
|
+
})
|
|
202
|
+
return verified
|
|
203
|
+
|
|
204
|
+
def _custom_element_cached(self, img, element_path: str) -> list[dict]:
|
|
205
|
+
"""Custom-Element-Matches als Boxes. Wie _locate_text, aber Template-Matching statt OCR:
|
|
206
|
+
Cache an -> Crop-Match um gecachte Boxen, bei Miss Full-Screen-Fallback und neu cachen."""
|
|
207
|
+
custom_element = ip.load_image(element_path)
|
|
208
|
+
eh, ew = custom_element.shape[:2]
|
|
209
|
+
|
|
210
|
+
def _run_full() -> list[dict]:
|
|
211
|
+
matches = ip.filter_matches(
|
|
212
|
+
ip.match_custom_element(img, custom_element, threshold=config.MATCH_THRESHOLD)
|
|
213
|
+
)
|
|
214
|
+
# match_custom_element liefert Mittelpunkte; daraus eine Box in Bildkoordinaten bauen.
|
|
215
|
+
return [{"left": cx - ew // 2, "top": cy - eh // 2, "width": ew, "height": eh}
|
|
216
|
+
for cx, cy in matches]
|
|
217
|
+
|
|
218
|
+
if not config.VISION_OCR_CACHE:
|
|
219
|
+
return _run_full()
|
|
220
|
+
|
|
221
|
+
key = self._screen_key()
|
|
222
|
+
cached = _cache.get_custom_element_matches(*key, element_path) if key is not None else None
|
|
223
|
+
verified = self._verify_cached_boxes(img, custom_element, cached, ew, eh) if cached is not None else []
|
|
224
|
+
if verified:
|
|
225
|
+
logger.info("VISION_CACHE event=hit keyword=custom_element_cached element_path=%r boxes=%d",
|
|
226
|
+
element_path, len(verified))
|
|
227
|
+
return verified # Treffer: Full-Screen-Match übersprungen
|
|
228
|
+
if cached is not None:
|
|
229
|
+
logger.info("VISION_CACHE event=verify_fail keyword=custom_element_cached element_path=%r boxes=%d",
|
|
230
|
+
element_path, len(cached))
|
|
231
|
+
else:
|
|
232
|
+
logger.info("VISION_CACHE event=miss keyword=custom_element_cached element_path=%r", element_path)
|
|
233
|
+
|
|
234
|
+
# Miss oder Crop-Verifikation fehlgeschlagen: voller Live-Discovery-Fallback.
|
|
235
|
+
boxes = _run_full()
|
|
236
|
+
if key is not None:
|
|
237
|
+
try:
|
|
238
|
+
_cache.put_custom_element_matches(*key, element_path, boxes)
|
|
239
|
+
except Exception:
|
|
240
|
+
pass # Cache-Schreibfehler darf die Aktion nie brechen
|
|
241
|
+
return boxes
|
|
242
|
+
|
|
243
|
+
def _find_candidates(self, img, lines: list, target: str, kind: str) -> list[dict]:
|
|
244
|
+
"""Kandidaten-Boxes für das Ziel: OCR-Zeilen (Text) oder Template-Matches (Icon)."""
|
|
245
|
+
if kind == "icon":
|
|
246
|
+
return self._custom_element_cached(img, target)
|
|
247
|
+
return [line for line in lines if target.lower() in line["text"].lower()]
|
|
248
|
+
|
|
249
|
+
def _locate_anchor_cached(self, img, anchor: str) -> tuple[dict, dict, dict] | None:
|
|
250
|
+
"""Cache-Lookup + Live-Crop-Verify für den Anker-Text. Gibt (live_anchor, cached_anchor,
|
|
251
|
+
entry) zurück oder None bei Cache-Miss."""
|
|
252
|
+
if not config.VISION_OCR_CACHE:
|
|
253
|
+
return None
|
|
254
|
+
key = self._screen_key()
|
|
255
|
+
if key is None:
|
|
256
|
+
return None
|
|
257
|
+
entry = _cache.get(*key)
|
|
258
|
+
if not entry:
|
|
259
|
+
return None
|
|
260
|
+
cached_anchor = next(
|
|
261
|
+
(ln for ln in entry.get("elements", []) if anchor.lower() in ln["text"].lower()), None
|
|
262
|
+
)
|
|
263
|
+
if cached_anchor is None:
|
|
264
|
+
return None
|
|
265
|
+
live_anchor = self._crop_ocr_match(img, cached_anchor, anchor)
|
|
266
|
+
if live_anchor is None:
|
|
267
|
+
return None
|
|
268
|
+
return live_anchor, cached_anchor, entry
|
|
269
|
+
|
|
270
|
+
def _locate_relation(self, img, anchor: str, target: str) -> tuple[dict, list[dict]] | None:
|
|
271
|
+
"""Cache-gestützter Lookup für Anker + Text-Ziel-Kandidaten, alle Boxen live crop-verifiziert.
|
|
272
|
+
None bei Cache-aus/Miss -> find_element fällt dann auf Full-OCR zurück."""
|
|
273
|
+
located = self._locate_anchor_cached(img, anchor)
|
|
274
|
+
if located is None:
|
|
275
|
+
return None
|
|
276
|
+
live_anchor, cached_anchor, entry = located
|
|
277
|
+
live_candidates = []
|
|
278
|
+
for ln in entry.get("elements", []):
|
|
279
|
+
# Anker nicht als eigenes Ziel: Identität auf den Cache-Zeilen (Analogie zu
|
|
280
|
+
# `c is not anchor_box` im Full-OCR-Pfad). Merge-Fall -> kein Kandidat -> Full-OCR-Fallback.
|
|
281
|
+
if ln is cached_anchor:
|
|
282
|
+
continue
|
|
283
|
+
if target.lower() in ln["text"].lower():
|
|
284
|
+
live = self._crop_ocr_match(img, ln, target)
|
|
285
|
+
if live is not None:
|
|
286
|
+
live_candidates.append(live)
|
|
287
|
+
if not live_candidates:
|
|
288
|
+
return None
|
|
289
|
+
return live_anchor, live_candidates
|
|
290
|
+
|
|
291
|
+
def find_element(self, target: str, anchor: str, relation: str = "near", kind: str = "auto") -> tuple[int, int]:
|
|
292
|
+
"""Lokalisiert das Ziel live relativ zum Anker-Text und gibt den Mittelpunkt (x, y)
|
|
293
|
+
zurück. Der Cache dient nur als Kandidatenquelle, Koordinaten stammen immer live."""
|
|
294
|
+
for attempt in (0, 1):
|
|
295
|
+
try:
|
|
296
|
+
return self._find_element_once(target, anchor, relation, kind)
|
|
297
|
+
except RuntimeError:
|
|
298
|
+
if attempt == 0:
|
|
299
|
+
# Kein Treffer - kurze Pause für den Fall, dass Inhalte (z. B. WebView2-Content
|
|
300
|
+
# nach einer Navigation) noch nicht fertig gerendert sind.
|
|
301
|
+
time.sleep(1.0)
|
|
302
|
+
continue
|
|
303
|
+
raise
|
|
304
|
+
|
|
305
|
+
def _cached_anchor_and_candidates(self, img, target: str, anchor: str, kind: str) -> tuple[dict, list[dict]] | None:
|
|
306
|
+
"""Cache-Pfad für Anker + Kandidaten, ohne Full-OCR. None wenn der Cache keinen
|
|
307
|
+
brauchbaren Treffer liefert, dann fällt _find_element_once auf Full-OCR zurück."""
|
|
308
|
+
if kind == "text":
|
|
309
|
+
return self._locate_relation(img, anchor, target)
|
|
310
|
+
# kind == "icon": Anker per Cache (kein Full-OCR), Icon-Kandidaten per _custom_element_cached
|
|
311
|
+
# (hat selbst einen Full-Screen-Fallback, falls das Template warm nicht matcht).
|
|
312
|
+
located = self._locate_anchor_cached(img, anchor)
|
|
313
|
+
if located is None:
|
|
314
|
+
return None
|
|
315
|
+
return located[0], self._find_candidates(img, [], target, "icon")
|
|
316
|
+
|
|
317
|
+
def _full_ocr_anchor_and_candidates(self, img, target: str, anchor: str, kind: str) -> tuple[dict, list[dict]]:
|
|
318
|
+
"""Full-OCR-Fallback: sucht Anker und Kandidaten live im gesamten Frame, cached das
|
|
319
|
+
Zeilen-Layout fürs nächste Mal (Anker ist immer Text)."""
|
|
320
|
+
lines = _ocr(img)
|
|
321
|
+
if config.VISION_OCR_CACHE:
|
|
322
|
+
key = self._screen_key()
|
|
323
|
+
if key is not None:
|
|
324
|
+
try:
|
|
325
|
+
_cache.put(key[0], key[1], key[2], key[3], elements=lines)
|
|
326
|
+
except Exception:
|
|
327
|
+
pass
|
|
328
|
+
anchor_box = next((line for line in lines if anchor.lower() in line["text"].lower()), None)
|
|
329
|
+
if anchor_box is None:
|
|
330
|
+
raise VisionMatchError(f"Anchor text not found: '{anchor}'", screenshot=img)
|
|
331
|
+
candidates = self._find_candidates(img, lines, target, kind)
|
|
332
|
+
candidates = [c for c in candidates if c is not anchor_box] # Anker nicht als eigenes Ziel
|
|
333
|
+
return anchor_box, candidates
|
|
334
|
+
|
|
335
|
+
def _find_element_once(self, target: str, anchor: str, relation: str, kind: str) -> tuple[int, int]:
|
|
336
|
+
kind = self._resolve_type(target, kind)
|
|
337
|
+
img = self._device.screenshot()
|
|
338
|
+
|
|
339
|
+
cached = self._cached_anchor_and_candidates(img, target, anchor, kind)
|
|
340
|
+
logger.info("VISION_CACHE event=%s keyword=find_element target=%r anchor=%r kind=%s",
|
|
341
|
+
"hit" if cached is not None else "miss", target, anchor, kind)
|
|
342
|
+
anchor_box, candidates = (
|
|
343
|
+
cached if cached is not None else self._full_ocr_anchor_and_candidates(img, target, anchor, kind)
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
if not candidates:
|
|
347
|
+
raise VisionMatchError(f"Target not found: '{target}'", screenshot=img)
|
|
348
|
+
best = spatial.resolve(
|
|
349
|
+
anchor_box, candidates, relation,
|
|
350
|
+
overlap_tol=config.SPATIAL_OVERLAP_TOLERANCE,
|
|
351
|
+
max_distance=config.SPATIAL_NEAR_MAX_DISTANCE,
|
|
352
|
+
)
|
|
353
|
+
if best is None:
|
|
354
|
+
raise VisionMatchError(f"No '{target}' {relation} '{anchor}'", screenshot=img)
|
|
355
|
+
return spatial.center(best)
|
|
356
|
+
|
|
357
|
+
def tap_element(self, target: str, anchor: str, relation: str = "near", kind: str = "auto") -> None:
|
|
358
|
+
# Re-lokalisiert live und tippt sofort, kein Tippen auf zwischengespeicherte Koordinaten.
|
|
359
|
+
x, y = self.find_element(target, anchor, relation=relation, kind=kind)
|
|
360
|
+
self._device.tap(x, y)
|
|
361
|
+
|
|
362
|
+
def _disambiguate(self, boxes: list[dict], locator: str, region: tuple[int, int, int, int] | None,
|
|
363
|
+
img) -> dict:
|
|
364
|
+
"""Wählt bei mehreren Treffern den einen Treffer innerhalb von region. Ohne
|
|
365
|
+
bestimmbaren Bereich wird kontrolliert abgebrochen statt zu raten."""
|
|
366
|
+
if region is None:
|
|
367
|
+
raise VisionMatchError(
|
|
368
|
+
f"Mehrdeutig: {len(boxes)} Treffer für '{locator}', kein aktiver Dialog- oder "
|
|
369
|
+
"Feldbereich erkennbar. Eindeutigen Locator verwenden.",
|
|
370
|
+
screenshot=img,
|
|
371
|
+
)
|
|
372
|
+
left, top, right, bottom = region
|
|
373
|
+
in_region = [b for b in boxes if left <= spatial.center(b)[0] <= right
|
|
374
|
+
and top <= spatial.center(b)[1] <= bottom]
|
|
375
|
+
if len(in_region) != 1:
|
|
376
|
+
raise VisionMatchError(
|
|
377
|
+
f"Mehrdeutig: {len(in_region)} von {len(boxes)} Treffern für '{locator}' im "
|
|
378
|
+
"aktiven Dialogbereich. Eindeutigen Locator verwenden.",
|
|
379
|
+
screenshot=img,
|
|
380
|
+
)
|
|
381
|
+
return in_region[0]
|
|
382
|
+
|
|
383
|
+
def input_text(self, locator: str, text: str) -> None:
|
|
384
|
+
"""Lokalisiert das Feld per locator, tippt den Mittelpunkt, verifiziert den Fokus im
|
|
385
|
+
Zielprozess und gibt dann literalen Text ein. Ohne verifizierten Fokus wird nicht
|
|
386
|
+
geschrieben."""
|
|
387
|
+
kind = self._resolve_type(locator, "auto")
|
|
388
|
+
img = self._device.screenshot()
|
|
389
|
+
|
|
390
|
+
try:
|
|
391
|
+
region = self._device.active_region()
|
|
392
|
+
supports_active_region = True
|
|
393
|
+
except NotImplementedError:
|
|
394
|
+
region = None
|
|
395
|
+
supports_active_region = False
|
|
396
|
+
|
|
397
|
+
if not supports_active_region:
|
|
398
|
+
if kind == "icon":
|
|
399
|
+
boxes = self._custom_element_cached(img, locator)
|
|
400
|
+
if not boxes:
|
|
401
|
+
raise VisionMatchError(f"Eingabefeld nicht gefunden (Custom Element): '{locator}'", screenshot=img)
|
|
402
|
+
box = boxes[0]
|
|
403
|
+
else:
|
|
404
|
+
box = self._locate_text(locator, img)
|
|
405
|
+
if box is None:
|
|
406
|
+
raise VisionMatchError(f"Eingabefeld nicht gefunden (Text): '{locator}'", screenshot=img)
|
|
407
|
+
else:
|
|
408
|
+
if kind == "icon":
|
|
409
|
+
boxes = self._custom_element_cached(img, locator)
|
|
410
|
+
else:
|
|
411
|
+
boxes = [h for h in _ocr(img) if locator.lower() in h["text"].lower()]
|
|
412
|
+
if not boxes:
|
|
413
|
+
raise VisionMatchError(f"Eingabefeld nicht gefunden ({kind}): '{locator}'", screenshot=img)
|
|
414
|
+
if len(boxes) == 1:
|
|
415
|
+
box = boxes[0]
|
|
416
|
+
else:
|
|
417
|
+
if region is None and kind == "text":
|
|
418
|
+
# Zweites Signal für Apps ohne eigenes Overlay-Fenster.
|
|
419
|
+
try:
|
|
420
|
+
region = self._device.value_region(locator)
|
|
421
|
+
except NotImplementedError:
|
|
422
|
+
region = None
|
|
423
|
+
logger.info(
|
|
424
|
+
"input_text event=value_region_lookup locator=%r result=%s",
|
|
425
|
+
locator, "hit" if region is not None else "miss",
|
|
426
|
+
)
|
|
427
|
+
box = self._disambiguate(boxes, locator, region, img)
|
|
428
|
+
|
|
429
|
+
cx = box["left"] + box["width"] // 2
|
|
430
|
+
cy = box["top"] + box["height"] // 2
|
|
431
|
+
self._device.tap(cx, cy)
|
|
432
|
+
time.sleep(0.3)
|
|
433
|
+
if not self._device.verify_focus_in_target():
|
|
434
|
+
raise VisionMatchError(
|
|
435
|
+
f"Fokus nach Click auf '{locator}' liegt nicht im verbundenen Zielprozess, "
|
|
436
|
+
"Eingabe abgebrochen.",
|
|
437
|
+
screenshot=img,
|
|
438
|
+
)
|
|
439
|
+
try:
|
|
440
|
+
self._device.clear_focused_field(cx, cy)
|
|
441
|
+
except NotImplementedError:
|
|
442
|
+
pass
|
|
443
|
+
except Exception as exc:
|
|
444
|
+
raise VisionMatchError(
|
|
445
|
+
f"Feldinhalt vor Eingabe in '{locator}' konnte nicht gelöscht oder verifiziert "
|
|
446
|
+
f"werden: {exc}",
|
|
447
|
+
screenshot=img,
|
|
448
|
+
) from exc
|
|
449
|
+
self._device.type_text(text)
|
|
450
|
+
|
|
451
|
+
def tap_icon_near_text(self, icon_path: str, text: str) -> None:
|
|
452
|
+
# Rückwärtskompatibel: "Icon neben Text" ist der near-Spezialfall der Relations-API.
|
|
453
|
+
self.tap_element(icon_path, anchor=text, relation="near", kind="icon")
|
|
454
|
+
|
|
455
|
+
def scroll_to_text(self, text: str, anchor: str | None = None, relation: str = "near") -> bool:
|
|
456
|
+
img = self._device.screenshot()
|
|
457
|
+
height, width = img.shape[0], img.shape[1]
|
|
458
|
+
step = int(height * config.VISION_SCROLL_STEP_PERCENT)
|
|
459
|
+
center = height // 2
|
|
460
|
+
x = width // 2
|
|
461
|
+
hits = _ocr(img)
|
|
462
|
+
|
|
463
|
+
if anchor is not None:
|
|
464
|
+
anchor_box = next((h for h in hits if anchor.lower() in h["text"].lower()), None)
|
|
465
|
+
if anchor_box is not None:
|
|
466
|
+
x, center = spatial.center(anchor_box)
|
|
467
|
+
# Anker im Startbild nicht sichtbar: Fallback auf Bildschirmmitte, kein Hard-Error
|
|
468
|
+
# (Anker könnte erst nach Scrollen erscheinen)
|
|
469
|
+
|
|
470
|
+
if self._resolve_target_hit(hits, text, anchor, relation) is not None:
|
|
471
|
+
return True
|
|
472
|
+
if self._scroll_and_search(x, center, step, text, direction=1, anchor=anchor, relation=relation):
|
|
473
|
+
return True
|
|
474
|
+
return self._scroll_and_search(x, center, step, text, direction=-1, anchor=anchor, relation=relation)
|