Files
Artemis-Eapil/tests/unit/utils/test_native_control_state.py

210 lines
7.8 KiB
Python

"""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) == ("", [], [])