mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 22:27:01 +01:00
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
Android runs one instrumentation per package: a second `am instrument`
force-stops the first ("stop com.kuhy.a11ydump due to start instr"), and
so does `install -r`. The live two-session run on the Pixel caught two
sessions' dumps killing each other ("Process crashed"). dump_display now
runs install + instrument under a per-serial flock; a dump takes well
under a second, so queueing costs nothing noticeable.
After the fix the same 300 s run had 0 failures across 659 actions, and
the real screen and both apps stayed where they were.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpA6wW2DKiovQADTdjWJoH
210 lines
8.2 KiB
Python
210 lines
8.2 KiB
Python
"""Tests for :mod:`python_pkg.android_ui.driver`.
|
|
|
|
The cases mirror failures actually observed driving a Pixel 6a on 2026-08-10,
|
|
because each of them looked like success at the time:
|
|
|
|
* a tap that landed on an unfocused field, so the typed text went nowhere;
|
|
* a widget behind the soft keyboard whose reported position was 390px off;
|
|
* ``uiautomator dump`` returning only the focused ``EditText`` nodes;
|
|
* ``input text`` appending to a filled field instead of replacing it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import NamedTuple
|
|
|
|
import pytest
|
|
|
|
from python_pkg.android_ui import driver as drv
|
|
from python_pkg.android_ui.tests.conftest import FakeDevice
|
|
from python_pkg.android_ui.tests.conftest import node as _node
|
|
from python_pkg.android_ui.tests.conftest import tree as _tree
|
|
|
|
MOD = "python_pkg.android_ui.driver"
|
|
|
|
|
|
class Harness(NamedTuple):
|
|
"""A driver plus the fake device backing it.
|
|
|
|
Returned as a pair rather than by bolting the device onto the driver, so
|
|
the test needs no attribute that production code does not have.
|
|
"""
|
|
|
|
ui: drv.AndroidUi
|
|
device: FakeDevice
|
|
|
|
|
|
@pytest.fixture
|
|
def ui(monkeypatch: pytest.MonkeyPatch) -> Harness:
|
|
"""An :class:`AndroidUi` wired to a :class:`FakeDevice`."""
|
|
device = FakeDevice()
|
|
instance = drv.AndroidUi(settle_seconds=0.0)
|
|
monkeypatch.setattr(instance, "_run", device.run)
|
|
monkeypatch.setattr(f"{MOD}.time.sleep", lambda _s: None)
|
|
return Harness(ui=instance, device=device)
|
|
|
|
|
|
class TestParseTree:
|
|
def test_keeps_labelled_nodes(self) -> None:
|
|
elements = drv._parse_tree(_tree(_node("Connect Firebase")))
|
|
assert [e.text for e in elements] == ["Connect Firebase"]
|
|
|
|
def test_keeps_empty_edit_text(self) -> None:
|
|
# An empty field has no text, content-desc or resource-id, yet it is
|
|
# exactly the element a caller must address in order to fill it in.
|
|
xml = _tree(_node(cls="android.widget.EditText"))
|
|
assert len(drv._parse_tree(xml)) == 1
|
|
|
|
def test_drops_unlabelled_non_editable_nodes(self) -> None:
|
|
assert drv._parse_tree(_tree(_node())) == []
|
|
|
|
def test_drops_nodes_without_bounds(self) -> None:
|
|
xml = _tree('<node text="x" class="android.widget.TextView"/>')
|
|
assert drv._parse_tree(xml) == []
|
|
|
|
def test_malformed_xml_is_an_empty_screen_not_a_crash(self) -> None:
|
|
# A dump taken mid-animation is truncated; callers retry rather than die.
|
|
assert drv._parse_tree("<hierarchy><node") == []
|
|
|
|
|
|
class TestUiElement:
|
|
def test_center_is_the_middle_of_current_bounds(self) -> None:
|
|
element = drv._parse_tree(_tree(_node("A", bounds="[10,20][30,60]")))[0]
|
|
assert element.center == (20, 40)
|
|
|
|
def test_label_prefers_text_then_desc_then_id(self) -> None:
|
|
by_desc = drv._parse_tree(_tree(_node(desc="described")))[0]
|
|
by_id = drv._parse_tree(_tree(_node(res="com.x:id/y")))[0]
|
|
assert by_desc.label == "described"
|
|
assert by_id.label == "com.x:id/y"
|
|
|
|
def test_substring_match_is_case_insensitive(self) -> None:
|
|
element = drv._parse_tree(_tree(_node("Connect Firebase")))[0]
|
|
assert element.matches("connect")
|
|
assert not element.matches("connect", exact=True)
|
|
assert element.matches("Connect Firebase", exact=True)
|
|
|
|
def test_str_names_the_element_and_its_centre(self) -> None:
|
|
element = drv._parse_tree(
|
|
_tree(_node("Go", cls="android.widget.Button", bounds="[0,0][10,10]"))
|
|
)[0]
|
|
assert str(element) == "'Go' <Button> at (5,5)"
|
|
|
|
|
|
class TestFind:
|
|
def test_returns_the_single_match(self, ui: Harness) -> None:
|
|
ui.device.trees = [_tree(_node("Alpha"), _node("Beta"), _node("Gamma"))]
|
|
assert ui.ui.find("Beta").text == "Beta"
|
|
|
|
def test_missing_element_raises_and_names_what_is_on_screen(
|
|
self, ui: Harness
|
|
) -> None:
|
|
ui.device.trees = [_tree(_node("Alpha"), _node("Beta"), _node("Gamma"))]
|
|
with pytest.raises(drv.ElementNotFoundError, match="Alpha"):
|
|
ui.ui.find("Nope")
|
|
|
|
def test_ambiguity_is_an_error_not_a_first_match(self, ui: Harness) -> None:
|
|
# `tap "Back"` must not silently hit "OFFLINE BACKUP".
|
|
ui.device.trees = [_tree(_node("Back"), _node("OFFLINE BACKUP"))]
|
|
with pytest.raises(drv.AmbiguousElementError, match="2 elements"):
|
|
ui.ui.find("Back")
|
|
|
|
def test_exact_disambiguates(self, ui: Harness) -> None:
|
|
ui.device.trees = [_tree(_node("Back"), _node("OFFLINE BACKUP"))]
|
|
assert ui.ui.find("Back", exact=True).text == "Back"
|
|
|
|
|
|
class TestDump:
|
|
def test_retries_while_the_tree_looks_partial(self, ui: Harness) -> None:
|
|
# Flutter reports only the focused EditText while the keyboard is up.
|
|
partial = _tree(_node(cls="android.widget.EditText"))
|
|
full = _tree(*[_node(f"n{i}") for i in range(8)])
|
|
ui.device.trees = [partial, partial, full]
|
|
assert len(ui.ui.dump()) == 8
|
|
|
|
def test_returns_the_best_seen_when_every_retry_is_partial(
|
|
self, ui: Harness
|
|
) -> None:
|
|
ui.device.trees = [_tree(), _tree(_node("only"))]
|
|
assert [e.text for e in ui.ui.dump()] == ["only"]
|
|
|
|
|
|
class TestWaitFor:
|
|
def test_polls_until_the_element_appears(self, ui: Harness) -> None:
|
|
ui.device.trees = [_tree(_node("nope")), _tree(_node("Connected."))]
|
|
assert ui.ui.wait_for("Connected.", timeout=5).text == "Connected."
|
|
|
|
def test_times_out_with_the_last_reason(self, ui: Harness) -> None:
|
|
ui.device.trees = [_tree(_node("nope"))]
|
|
with pytest.raises(drv.ElementNotFoundError, match="waited"):
|
|
ui.ui.wait_for("Connected.", timeout=0.01)
|
|
|
|
|
|
class TestTap:
|
|
def test_taps_the_centre_of_the_resolved_element(self, ui: Harness) -> None:
|
|
ui.device.trees = [_tree(_node("Go", bounds="[100,200][300,400]"))]
|
|
ui.ui.tap("Go")
|
|
assert ui.device.taps() == [(200, 300)]
|
|
|
|
def test_closes_the_keyboard_before_tapping(self, ui: Harness) -> None:
|
|
# The tree reports a widget's LAID-OUT position even when the keyboard
|
|
# covers it, so tapping blind lands on a letter key.
|
|
ui.device.keyboard_shown = True
|
|
ui.device.trees = [_tree(_node("Go", bounds="[0,0][100,100]"))]
|
|
ui.ui.tap("Go")
|
|
keyevents = [
|
|
c
|
|
for c in ui.device.calls
|
|
if c[:5] == ("shell", "input", "-d", "0", "keyevent")
|
|
]
|
|
assert keyevents, "expected the keyboard to be dismissed first"
|
|
|
|
|
|
def test_hint_names_an_empty_material_field() -> None:
|
|
"""A Flutter/Material field publishes its label only as ``hint``.
|
|
|
|
Such a field carries no text and no content-desc, so without hint it is an
|
|
anonymous edit box and the only way to fill it is by screen position --
|
|
which is how a mis-tap silently writes a value into the wrong field.
|
|
"""
|
|
dump = _node(cls="android.widget.EditText", hint="Location name")
|
|
(element,) = drv._parse_tree(_tree(dump))
|
|
|
|
assert element.label == "Location name"
|
|
assert element.matches("location")
|
|
|
|
|
|
def test_real_text_outranks_the_hint_once_typed() -> None:
|
|
"""Material clears the hint when content arrives, so text must win.
|
|
|
|
Otherwise a filled field would still answer to its placeholder and a
|
|
caller could not tell an empty field from a completed one.
|
|
"""
|
|
dump = _node("17:30", cls="android.widget.EditText", hint="Start time (HH:MM)")
|
|
(element,) = drv._parse_tree(_tree(dump))
|
|
|
|
assert element.label == "17:30"
|
|
|
|
|
|
class TestVirtualDisplay:
|
|
def test_reads_the_tree_through_the_helper_and_taps_with_d(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
device = FakeDevice()
|
|
instance = drv.AndroidUi(serial="S", display=9, settle_seconds=0.0)
|
|
monkeypatch.setattr(instance, "_run", device.run)
|
|
monkeypatch.setattr(f"{MOD}.time.sleep", lambda _s: None)
|
|
xml = _tree(_node("Go", bounds="[0,0][100,100]"))
|
|
calls: list[tuple[int, str]] = []
|
|
|
|
def dump(run: object, display: int, phone: str) -> str:
|
|
del run
|
|
calls.append((display, phone))
|
|
return xml
|
|
|
|
monkeypatch.setattr(f"{MOD}._a11y_helper.dump_display", dump)
|
|
instance.tap("Go")
|
|
assert calls
|
|
assert set(calls) == {(9, "S")}
|
|
assert ("shell", "input", "-d", "9", "tap", "50", "50") in device.calls
|