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,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)