mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 19:47:12 +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
129 lines
4.2 KiB
Python
129 lines
4.2 KiB
Python
"""The element and error types the UI driver works in, and the XML parser.
|
|
|
|
Split out of :mod:`python_pkg.android_ui.driver` to keep it under the 250-line
|
|
cap. ``AndroidUi`` stays there because that is the name the tests patch;
|
|
everything here is re-imported by ``driver`` so the package's public re-exports
|
|
in ``__init__`` are unchanged.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
import logging
|
|
import re
|
|
|
|
from defusedxml.ElementTree import ParseError, fromstring
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
_BOUNDS_RE = re.compile(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]")
|
|
|
|
|
|
class UiAutomationError(RuntimeError):
|
|
"""Base class for every failure this package reports."""
|
|
|
|
|
|
class ElementNotFoundError(UiAutomationError):
|
|
"""No element matched the query."""
|
|
|
|
|
|
class AmbiguousElementError(UiAutomationError):
|
|
"""More than one element matched, so acting would be a coin flip."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class UiElement:
|
|
"""One node from the accessibility tree."""
|
|
|
|
text: str
|
|
content_desc: str
|
|
resource_id: str
|
|
class_name: str
|
|
bounds: tuple[int, int, int, int]
|
|
enabled: bool
|
|
focused: bool
|
|
|
|
@property
|
|
def label(self) -> str:
|
|
"""The best human-facing name for this element."""
|
|
return self.text or self.content_desc or self.resource_id
|
|
|
|
@property
|
|
def center(self) -> tuple[int, int]:
|
|
"""Tap point: the centre of the element's CURRENT bounds."""
|
|
left, top, right, bottom = self.bounds
|
|
return ((left + right) // 2, (top + bottom) // 2)
|
|
|
|
def matches(self, query: str, *, exact: bool = False) -> bool:
|
|
"""Return True if ``query`` names this element."""
|
|
haystacks = (self.text, self.content_desc, self.resource_id)
|
|
if exact:
|
|
return any(h == query for h in haystacks)
|
|
return any(query.lower() in h.lower() for h in haystacks if h)
|
|
|
|
def __str__(self) -> str:
|
|
"""Return a one-line description naming the element and its centre."""
|
|
cls = self.class_name.rsplit(".", 1)[-1]
|
|
x, y = self.center
|
|
return f"{self.label!r} <{cls}> at ({x},{y})"
|
|
|
|
|
|
def _escape(text: str) -> str:
|
|
r"""Escape text for ``adb shell input text``.
|
|
|
|
Backslash is escaped FIRST and excluded from the loop below. Escaping it
|
|
inside the loop double-escapes every backslash the loop itself just added
|
|
(``&`` -> ``\\&`` -> ``\\\\&``), which silently types the wrong password.
|
|
"""
|
|
out = text.replace("\\", "\\\\")
|
|
out = out.replace("%", "%%").replace(" ", "%s")
|
|
for char in "()<>|;&*~\"'`$":
|
|
out = out.replace(char, "\\" + char)
|
|
return out
|
|
|
|
|
|
def _parse_tree(xml: str) -> list[UiElement]:
|
|
"""Return every labelled node in an accessibility-tree dump."""
|
|
try:
|
|
root = fromstring(xml)
|
|
except ParseError as exc:
|
|
_logger.warning(
|
|
"UI dump is not valid XML (%s) — treating it as an empty screen; "
|
|
"this is usually a snapshot taken mid-animation",
|
|
exc,
|
|
)
|
|
return []
|
|
|
|
elements: list[UiElement] = []
|
|
for node in root.iter("node"):
|
|
bounds = _BOUNDS_RE.match(node.get("bounds", ""))
|
|
if bounds is None:
|
|
continue
|
|
text = node.get("text", "")
|
|
desc = node.get("content-desc", "")
|
|
res = node.get("resource-id", "")
|
|
cls = node.get("class", "")
|
|
# An EMPTY text field has no text, content-desc or resource-id, so a
|
|
# "must be labelled" filter drops it -- and an empty field is exactly
|
|
# the thing a caller needs to find in order to type into it. Keep every
|
|
# editable node regardless of label.
|
|
if not (text or desc or res or cls.endswith("EditText")):
|
|
continue
|
|
elements.append(
|
|
UiElement(
|
|
text=text,
|
|
content_desc=desc,
|
|
resource_id=res,
|
|
class_name=cls,
|
|
bounds=(
|
|
int(bounds.group(1)),
|
|
int(bounds.group(2)),
|
|
int(bounds.group(3)),
|
|
int(bounds.group(4)),
|
|
),
|
|
enabled=node.get("enabled") == "true",
|
|
focused=node.get("focused") == "true",
|
|
)
|
|
)
|
|
return elements
|