feat(android): add native controls and continuous multi-touch
This commit is contained in:
1 parent
3dd96078db
commit
4b12ae7728
70 files changed
+13105
-179
No files matched your search
@@ -0,0 +1,209 @@
|
||||
"""Hierarchy native semantics must survive both supported observation encodings."""
|
||||
|
||||
from copy import deepcopy
|
||||
import json
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import pytest
|
||||
|
||||
from artemis.clients.accessibility_client import normalize_helper_elements
|
||||
from artemis.clients.ui_automator_client import _parse_hierarchy_xml_to_elements
|
||||
from artemis.utils.native_control_state import informative_native_control, native_control_state
|
||||
from artemis.utils.visualization import format_minimal_list_with_elements
|
||||
|
||||
|
||||
RANGE = {"min": 0, "max": 100, "current": 37.5, "type": 1}
|
||||
ACTIONS = ["set_progress", "set_selection", "set_text"]
|
||||
|
||||
|
||||
def hierarchy_node(**overrides):
|
||||
return {
|
||||
"text": "",
|
||||
"content-desc": "",
|
||||
"class": "android.widget.SeekBar",
|
||||
"package": "com.artemis.fixture",
|
||||
"resource-id": "com.artemis.fixture:id/range",
|
||||
"bounds": "[10,20][210,80]",
|
||||
"enabled": True,
|
||||
"editable": False,
|
||||
"password": False,
|
||||
"checked": False,
|
||||
"checkable": False,
|
||||
"selected": False,
|
||||
"focused": False,
|
||||
"scrollable": False,
|
||||
"visible-to-user": True,
|
||||
"selection_start": -1,
|
||||
"selection_end": -1,
|
||||
"range": deepcopy(RANGE),
|
||||
"actions": list(ACTIONS),
|
||||
**overrides,
|
||||
}
|
||||
|
||||
|
||||
def encode_node(node, encoding):
|
||||
if encoding == "json":
|
||||
return normalize_helper_elements(json.loads(json.dumps([node])))[0]
|
||||
attributes = {}
|
||||
for key, value in node.items():
|
||||
name = key.replace("_", "-") if key in {"selection_start", "selection_end"} else key
|
||||
attributes[name] = (
|
||||
json.dumps(value, ensure_ascii=False)
|
||||
if isinstance(value, (dict, list, bool)) or value is None
|
||||
else str(value)
|
||||
)
|
||||
root = ET.Element("hierarchy")
|
||||
ET.SubElement(root, "node", attributes)
|
||||
return _parse_hierarchy_xml_to_elements(ET.tostring(root, encoding="unicode"))[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("encoding", ["xml", "json"])
|
||||
def test_native_range_actions_flags_and_utf16_selection_map_equally_from_xml_and_json(encoding):
|
||||
state = native_control_state(
|
||||
encode_node(hierarchy_node(editable=True, selection_start=1, selection_end=3), encoding)
|
||||
)
|
||||
assert state == {
|
||||
"range": RANGE,
|
||||
"actions": ACTIONS,
|
||||
"enabled": True,
|
||||
"scrollable": False,
|
||||
"editable": True,
|
||||
"password": False,
|
||||
"checked": False,
|
||||
"checkable": False,
|
||||
"selected": False,
|
||||
"focused": False,
|
||||
"selection_start": 1,
|
||||
"selection_end": 3,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("encoding", ["xml", "json"])
|
||||
@pytest.mark.parametrize(
|
||||
"semantics",
|
||||
[
|
||||
{"range": RANGE, "actions": []},
|
||||
{"range": None, "actions": ["set_progress"]},
|
||||
{"range": None, "actions": ["click"]},
|
||||
{"range": None, "actions": [], "scrollable": True},
|
||||
],
|
||||
)
|
||||
def test_unnamed_native_controls_are_discoverable_in_the_actual_visualization(encoding, semantics):
|
||||
node = encode_node(hierarchy_node(**semantics), encoding)
|
||||
text, elements, labels = format_minimal_list_with_elements([node], 400, 800)
|
||||
assert len(elements) == 1 and labels == ["1"]
|
||||
element = elements[0]
|
||||
assert element["text"] == "(native control)"
|
||||
assert element["native_text"] == "" and element["content_desc"] == ""
|
||||
assert element["resource_id"] == "com.artemis.fixture:id/range"
|
||||
assert element["package_name"] == "com.artemis.fixture"
|
||||
assert element["class"] == "android.widget.SeekBar"
|
||||
assert element["center"] == [110, 50] and element["bounds"] == [10, 20, 210, 80]
|
||||
assert "Control: '(native control)'" in text
|
||||
assert 'package_name: "com.artemis.fixture"' in text
|
||||
if semantics["range"] is not None:
|
||||
assert element["range"] == RANGE and '"current": 37.5' in text
|
||||
if semantics["actions"]:
|
||||
assert element["actions"] == semantics["actions"]
|
||||
assert "actions: " + ",".join(semantics["actions"]) in text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("encoding", ["xml", "json"])
|
||||
def test_native_text_description_package_and_selection_preserve_exact_observed_data(encoding):
|
||||
raw = hierarchy_node(
|
||||
text=" A😀B \n\t",
|
||||
**{
|
||||
"content-desc": " description \n\t",
|
||||
"class": "android.widget.EditText",
|
||||
"package": "com.artemis.fixture.editor",
|
||||
"editable": True,
|
||||
"selection_start": 1,
|
||||
"selection_end": 3,
|
||||
},
|
||||
)
|
||||
node = encode_node(raw, encoding)
|
||||
original = deepcopy(node)
|
||||
text, elements, _ = format_minimal_list_with_elements([node], 400, 800)
|
||||
element = elements[0]
|
||||
assert element["native_text"] == raw["text"]
|
||||
assert element["input_value"] == raw["text"]
|
||||
assert element["content_desc"] == raw["content-desc"]
|
||||
assert element["package_name"] == raw["package"]
|
||||
assert (element["selection_start"], element["selection_end"]) == (1, 3)
|
||||
assert element["range"] == RANGE and element["actions"] == ACTIONS
|
||||
assert json.dumps(raw["content-desc"], ensure_ascii=False) in text
|
||||
assert node == original
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad_range",
|
||||
[
|
||||
None,
|
||||
[],
|
||||
"invalid json",
|
||||
"{}",
|
||||
{"min": 0, "max": 100},
|
||||
{"min": 10, "max": 0, "current": 5},
|
||||
{"min": 0, "max": 10, "current": 11},
|
||||
{"min": False, "max": 10, "current": 1},
|
||||
{"min": "0", "max": 10, "current": 1},
|
||||
{"min": 0, "max": float("inf"), "current": 1},
|
||||
{"min": 0, "max": 10, "current": float("nan")},
|
||||
],
|
||||
)
|
||||
def test_malformed_range_does_not_hide_other_valid_native_metadata(bad_range):
|
||||
assert native_control_state({"range": bad_range, "actions": ["click"], "enabled": "true"}) == {
|
||||
"actions": ["click"],
|
||||
"enabled": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("encoding", ["xml", "json"])
|
||||
@pytest.mark.parametrize("field", ["min", "max", "current"])
|
||||
def test_huge_range_integers_are_omitted_without_crashing_observation(encoding, field):
|
||||
value = {**RANGE, field: 10**1000}
|
||||
node = encode_node(hierarchy_node(range=value), encoding)
|
||||
state = native_control_state(node)
|
||||
assert "range" not in state and state["actions"] == ACTIONS
|
||||
_, elements, _ = format_minimal_list_with_elements([node], 400, 800)
|
||||
assert len(elements) == 1 and "range" not in elements[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key", ["selection_start", "selection-start", "selection_end", "selection-end"]
|
||||
)
|
||||
@pytest.mark.parametrize("bad_offset", [True, 1.5, "1.5", "--1", "²", -2, 10**1000, "9" * 100])
|
||||
def test_invalid_or_impossible_selection_offsets_are_ignored_without_raising(key, bad_offset):
|
||||
state = native_control_state({key: bad_offset, "enabled": True})
|
||||
assert state == {"enabled": True}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [-1, 0, 3, 2**31 - 1, "-1", "0", "3", str(2**31 - 1)])
|
||||
def test_supported_native_int_selection_offsets_are_kept(value):
|
||||
assert native_control_state({"selection-start": value}) == {"selection_start": int(value)}
|
||||
|
||||
|
||||
def test_encoded_metadata_is_bounded_and_rejects_nonstring_action_names():
|
||||
state = native_control_state(
|
||||
{
|
||||
"range": " " * 8193,
|
||||
"actions": ["click", None, 7, False, "x" * 65, *["set_progress"] * 40],
|
||||
}
|
||||
)
|
||||
assert "range" not in state
|
||||
assert state["actions"] == ["click", *["set_progress"] * 27]
|
||||
assert "actions" not in native_control_state({"actions": " " * 8193})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [None, 0, 1, "TRUE", "False", "yes", {}, []])
|
||||
def test_metadata_flags_require_actual_boolean_values_or_canonical_xml_strings(value):
|
||||
assert native_control_state({"enabled": value}) == {}
|
||||
|
||||
|
||||
def test_passive_empty_layouts_do_not_become_actionable_controls():
|
||||
state = native_control_state({"actions": ["focus", "show_on_screen"], "range": None})
|
||||
assert not informative_native_control(state)
|
||||
text, elements, labels = format_minimal_list_with_elements(
|
||||
[hierarchy_node(actions=[], range=None)], 400, 800
|
||||
)
|
||||
assert (text, elements, labels) == ("", [], [])
|
||||
Reference in new issue
Block a user