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