mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-11 00:07:03 +01:00
android_ui, app_icons, token_audit, focus_policy and random_jpg are now entirely under 250 lines. Only wsg_grabber's six test modules remain in python_pkg. android_ui/driver.py 411 -> 205 took two cuts and a class split: the element and error types plus the XML parser to _elements.py, and the text-entry methods to a TextEntryMixin, using the ABC-with-abstract- methods shape because the tests call them as bound attributes. app_icons/glyphs.py 291 -> 93 by moving the twelve path fragments out. Three intermediate failures, all module-level globals that travelled with their code (_logger, re, _SAME_FIELD_X_TOLERANCE_PX) - the same class of miss as _make_verifier, which ruff cannot flag. Counts unchanged: 81, 137, 14, 44, 57. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
146 lines
6.0 KiB
Python
146 lines
6.0 KiB
Python
"""Typing into fields and driving the on-screen keyboard.
|
|
|
|
Split out of :mod:`python_pkg.android_ui.driver` to keep it under the 250-line
|
|
cap. A mixin rather than module-level functions because
|
|
``tests/test_driver.py`` calls these as bound attributes of a real driver, and
|
|
because they need ``_run``/``dump``/``wait_for`` from the concrete class.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from abc import ABC, abstractmethod
|
|
import re
|
|
import time
|
|
|
|
from python_pkg.android_ui._elements import (
|
|
ElementNotFoundError,
|
|
UiAutomationError,
|
|
UiElement,
|
|
_escape,
|
|
)
|
|
|
|
# Horizontal slack when re-identifying a field after typing: the keyboard can
|
|
# shift a widget vertically, but not sideways, so the left edge stays put.
|
|
_SAME_FIELD_X_TOLERANCE_PX = 50
|
|
|
|
|
|
class TextEntryMixin(ABC):
|
|
"""The text-entry half of :class:`~python_pkg.android_ui.driver.AndroidUi`."""
|
|
|
|
_settle: float
|
|
|
|
@abstractmethod
|
|
def _run(self, *args: str, timeout: float = 30.0) -> str:
|
|
"""Supplied by the concrete driver: run one adb command."""
|
|
|
|
@abstractmethod
|
|
def dump(self, *, retries: int = 4) -> list[UiElement]:
|
|
"""Supplied by the concrete driver: read the current UI tree."""
|
|
|
|
@abstractmethod
|
|
def wait_for(
|
|
self, query: str, *, timeout: float = 15.0, exact: bool = False
|
|
) -> UiElement:
|
|
"""Supplied by the concrete driver: block until an element appears."""
|
|
|
|
def _clear_focused_field(self, length: int) -> None:
|
|
"""Empty the focused field: select-all, then delete."""
|
|
if length == 0:
|
|
return
|
|
# KEYCODE_MOVE_END (123) then a run of deletes is more reliable across
|
|
# IMEs than CTRL+A, which not every keyboard honours.
|
|
self._run("shell", "input", "keyevent", "123")
|
|
for _ in range(length + 2):
|
|
self._run("shell", "input", "keyevent", "67")
|
|
time.sleep(self._settle)
|
|
|
|
def editable_fields(self) -> list[UiElement]:
|
|
"""Return every editable field on screen, in top-to-bottom order.
|
|
|
|
An EMPTY text field carries no text, content-desc or resource-id, so
|
|
there is nothing to name it by — yet it is precisely the element a
|
|
caller needs to address in order to fill it in. Ordering by position
|
|
gives a stable handle ("the second field in the sync form") that does
|
|
not depend on a label the widget never had.
|
|
"""
|
|
fields = [e for e in self.dump() if e.class_name.endswith("EditText")]
|
|
return sorted(fields, key=lambda e: (e.bounds[1], e.bounds[0]))
|
|
|
|
def type_into_field(self, index: int, text: str) -> None:
|
|
"""Type ``text`` into the ``index``-th editable field on screen.
|
|
|
|
Verifies the field changed, exactly like :meth:`type_into`.
|
|
"""
|
|
fields = self.editable_fields()
|
|
if index >= len(fields):
|
|
msg = f"asked for editable field #{index} but the screen has {len(fields)}"
|
|
raise ElementNotFoundError(msg)
|
|
target = fields[index]
|
|
before = target.text
|
|
x, y = target.center
|
|
self._run("shell", "input", "tap", str(x), str(y))
|
|
time.sleep(max(self._settle, 0.8))
|
|
# REPLACE, don't append. `input text` inserts at the cursor, so typing
|
|
# into a field that already holds something silently concatenates --
|
|
# producing e.g. "old@example.comnew@example.com", which is accepted by
|
|
# the widget, passes a "did the text change?" check, and is wrong.
|
|
self._clear_focused_field(len(before))
|
|
self._run("shell", "input", "text", _escape(text))
|
|
time.sleep(self._settle)
|
|
after = self.editable_fields()
|
|
changed = any(
|
|
f.text != before
|
|
and abs(f.bounds[0] - target.bounds[0]) < _SAME_FIELD_X_TOLERANCE_PX
|
|
for f in after
|
|
)
|
|
if not changed:
|
|
msg = (
|
|
f"typed {len(text)} character(s) into editable field #{index} "
|
|
f"but its contents did not change — the tap did not focus it"
|
|
)
|
|
raise UiAutomationError(msg)
|
|
|
|
def dismiss_keyboard(self) -> None:
|
|
"""Close the soft keyboard WITHOUT popping the current route.
|
|
|
|
``KEYCODE_BACK`` is the obvious way and the wrong one: Flutter treats
|
|
it as a route pop, so it navigates out of the screen and discards
|
|
anything typed. ``KEYCODE_ESCAPE`` leaves the route alone but does not
|
|
close every IME (Gboard ignores it), and a keyboard that stays up hides
|
|
the button you are about to tap -- so the tap lands on a key instead,
|
|
silently doing nothing useful.
|
|
|
|
So: ask the IME to hide, verify with ``dumpsys input_method``, and only
|
|
then report success. Raises if the keyboard is still up, because
|
|
"tapped a letter key" is indistinguishable from "tapped the button"
|
|
unless somebody checks.
|
|
"""
|
|
for keyevent in ("111", "4"):
|
|
if not self.keyboard_is_up():
|
|
return
|
|
self._run("shell", "input", "keyevent", keyevent)
|
|
time.sleep(max(self._settle, 0.6))
|
|
if self.keyboard_is_up():
|
|
msg = (
|
|
"the soft keyboard is still covering the screen after ESCAPE "
|
|
"and BACK — any tap below it will hit a key, not your target"
|
|
)
|
|
raise UiAutomationError(msg)
|
|
|
|
def keyboard_is_up(self) -> bool:
|
|
"""Return True while the soft keyboard is shown.
|
|
|
|
Without this, a caller cannot tell "the button is absent" from "the
|
|
button is behind the keyboard", and the accessibility tree reports the
|
|
button's laid-out position either way.
|
|
"""
|
|
out = self._run("shell", "dumpsys", "input_method")
|
|
match = re.search(r"mInputShown=(\w+)", out)
|
|
return match is not None and match.group(1) == "true"
|
|
|
|
def current_focus(self) -> str:
|
|
"""Return the focused window, for asserting which screen is up."""
|
|
out = self._run("shell", "dumpsys", "window")
|
|
match = re.search(r"mCurrentFocus=\S+ \S+ (\S+)}", out)
|
|
return match.group(1) if match else ""
|