Revert "refactor: clean up legacy code, rename hopper to entity_extractor, and add third-party notices"

This reverts commit 54fcef9887.

Please do not submit unrelated docs and codes in the same commit. I am reverting this commit so that the related files can be submitted in better-organized future commits.
This commit is contained in:
yaoyao-open-source committed 2026-09-11 19:32:22 -07:00
1 parent 54fcef9887
commit 1d82d59ec8
83 files changed
+3677 -2013

No files matched your search

+22 -23
View File
@@ -13,11 +13,10 @@
# limitations under the License.
from unittest.mock import MagicMock, patch
import pytest
from artemis.agents.entity_extractor.entity_extractor import ExtractionResult
from artemis.agents.hopper.hopper import HopperOutput
from artemis.context import ArtemisContext
from artemis.tools.mobile.launch_app import find_package
import pytest
@pytest.fixture
@@ -29,63 +28,63 @@ def mock_context():
@pytest.mark.asyncio
@patch("artemis.tools.mobile.launch_app.list_packages_async")
@patch("artemis.tools.mobile.launch_app.extract_entity")
async def test_find_package_cache_hit(mock_extract_entity, mock_list_packages, mock_context):
@patch("artemis.tools.mobile.launch_app.hopper")
async def test_find_package_cache_hit(mock_hopper, mock_list_packages, mock_context):
mock_list_packages.return_value = "com.example.app1\ncom.example.app2"
# First call: not in cache, calls extract_entity
mock_extract_entity.return_value = ExtractionResult(
# First call: not in cache, calls hopper
mock_hopper.return_value = HopperOutput(
found=True, output="com.example.app1", reason="Found it"
)
pkg1 = await find_package(mock_context, "App 1")
assert pkg1 == "com.example.app1"
assert mock_extract_entity.call_count == 1
assert mock_hopper.call_count == 1
assert mock_context.package_cache["App 1"] == "com.example.app1"
# Second call: cache hit, does not call extract_entity or list_packages again
mock_extract_entity.reset_mock()
# Second call: cache hit, does not call hopper or list_packages again
mock_hopper.reset_mock()
mock_list_packages.reset_mock()
pkg2 = await find_package(mock_context, "App 1")
assert pkg2 == "com.example.app1"
mock_extract_entity.assert_not_called()
mock_hopper.assert_not_called()
mock_list_packages.assert_not_called()
@pytest.mark.asyncio
@patch("artemis.tools.mobile.launch_app.list_packages_async")
@patch("artemis.tools.mobile.launch_app.extract_entity")
async def test_find_package_not_found_caches_none(mock_extract_entity, mock_list_packages, mock_context):
@patch("artemis.tools.mobile.launch_app.hopper")
async def test_find_package_not_found_caches_none(mock_hopper, mock_list_packages, mock_context):
mock_list_packages.return_value = "com.example.app1\ncom.example.app2"
# Extractor doesn't find it
mock_extract_entity.return_value = ExtractionResult(found=False, output=None, reason="Not found")
# Hopper doesn't find it
mock_hopper.return_value = HopperOutput(found=False, output=None, reason="Not found")
pkg1 = await find_package(mock_context, "App 3")
assert pkg1 is None
assert mock_extract_entity.call_count == 1
assert mock_hopper.call_count == 1
assert mock_context.package_cache["App 3"] is None
# Second call: cache hit (None), does not call extract_entity again
mock_extract_entity.reset_mock()
# Second call: cache hit (None), does not call hopper again
mock_hopper.reset_mock()
pkg2 = await find_package(mock_context, "App 3")
assert pkg2 is None
mock_extract_entity.assert_not_called()
mock_hopper.assert_not_called()
@pytest.mark.asyncio
@patch("artemis.tools.mobile.launch_app.list_packages_async")
@patch("artemis.tools.mobile.launch_app.extract_entity")
async def test_find_package_passes_use_fallback(mock_extract_entity, mock_list_packages, mock_context):
@patch("artemis.tools.mobile.launch_app.hopper")
async def test_find_package_passes_use_fallback(mock_hopper, mock_list_packages, mock_context):
mock_list_packages.return_value = "com.example.app1\ncom.example.app2"
mock_extract_entity.return_value = ExtractionResult(
mock_hopper.return_value = HopperOutput(
found=True, output="com.example.app1", reason="Found it"
)
# Pass use_fallback=False
await find_package(mock_context, "App 1", use_fallback=False)
mock_extract_entity.assert_called_once_with(
mock_hopper.assert_called_once_with(
ctx=mock_context,
request=("I'm looking for the package name of the following app: 'App 1'"),
data="com.example.app1\ncom.example.app2",
+357 -251
View File
@@ -12,340 +12,446 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for ARTEMIS tool utility functions and element interaction helpers."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import asyncio
import sys
from unittest.mock import Mock, patch
import pytest
from artemis.context import ArtemisContext, DeviceContext, DevicePlatform
from artemis.controllers.types import CoordinatesSelectorRequest
from artemis.tools.types import Target
from artemis.tools.utils import (
# Mock the problematic langgraph import at module level
_orig_chat_agent_executor = sys.modules.get("langgraph.prebuilt.chat_agent_executor")
_orig_state = sys.modules.get("artemis.graph.state")
sys.modules["langgraph.prebuilt.chat_agent_executor"] = Mock()
sys.modules["artemis.graph.state"] = Mock()
from artemis.context import DeviceContext, DevicePlatform, ArtemisContext # noqa: E402
from artemis.tools.types import Target # noqa: E402
from artemis.tools.utils import ( # noqa: E402
IdSelectorRequest,
IdWithTextSelectorRequest,
SelectorRequestWithCoordinates,
SelectorRequestWithPercentages,
TextSelectorRequest,
_extract_resource_id_and_text_from_selector,
find_element_by_text,
focus_element_if_needed,
has_valid_selectors,
move_cursor_to_end_if_bounds,
tap_bottom_right_of_element,
validate_coordinates_bounds,
)
from artemis.utils.ui_hierarchy import ElementBounds
from artemis.utils.ui_hierarchy import ElementBounds # noqa: E402
# Restore original modules to prevent contaminating other tests
if _orig_chat_agent_executor is not None:
sys.modules["langgraph.prebuilt.chat_agent_executor"] = _orig_chat_agent_executor
else:
del sys.modules["langgraph.prebuilt.chat_agent_executor"]
if _orig_state is not None:
sys.modules["artemis.graph.state"] = _orig_state
else:
del sys.modules["artemis.graph.state"]
@pytest.fixture
def dummy_context() -> MagicMock:
"""Fixture providing an ARTEMIS runtime context configured for Android."""
ctx = MagicMock(spec=ArtemisContext)
ctx.device = MagicMock(spec=DeviceContext)
def mock_context():
"""Create a mock ArtemisContext for testing."""
ctx = Mock(spec=ArtemisContext)
# Create device context with necessary attributes
ctx.device = Mock(spec=DeviceContext)
ctx.device.mobile_platform = DevicePlatform.ANDROID
ctx.device.device_id = "test-device-pixel8"
ctx.device.device_id = "test_device_123"
ctx.device.device_width = 1080
ctx.device.device_height = 2400
ctx.device.device_height = 2340
ctx.device.host_platform = "LINUX"
ctx.ui_adb_client = Mock()
# Mock the ADB client for Android
ctx.adb_client = Mock()
mock_device = Mock()
mock_device.shell = Mock(return_value="")
ctx.adb_client.device = Mock(return_value=mock_device)
# Mock the ADB client for Android
mock_response = Mock()
mock_response.json.return_value = {"elements": []}
ctx.ui_adb_client.get_screen_data = Mock(return_value=mock_response)
ctx.ui_adb_client.get_hierarchy = Mock(
return_value=(
b'<hierarchy><node bounds="[0,0][1000,1000]"><node'
b' bounds="[0,0][100,100]"/></node></hierarchy>'
)
)
return ctx
@pytest.fixture
def mock_graph_state() -> MagicMock:
"""Fixture providing a mock LangGraph state container."""
state = MagicMock()
def mock_state():
"""Create a mock State for testing."""
state = Mock()
state.latest_ui_hierarchy = []
return state
@pytest.fixture
def sample_hierarchy_node() -> dict:
"""Fixture providing a standard flat hierarchy node."""
def sample_element():
"""Create a sample UI element for testing."""
return {
"resourceId": "com.google.android.apps:id/search_query",
"text": "Search Google",
"bounds": {"x": 120, "y": 250, "width": 400, "height": 80},
"resourceId": "com.example:id/text_input",
"text": "Sample text",
"bounds": {"x": 100, "y": 200, "width": 300, "height": 50},
"focused": "false",
}
@pytest.fixture
def sample_nested_node() -> dict:
"""Fixture providing a rich nested hierarchy node."""
def sample_rich_element():
"""Create a sample rich UI element for testing."""
return {
"attributes": {
"resource-id": "com.google.android.apps:id/search_query",
"text": "Search Google",
"bounds": {"x": 120, "y": 250, "width": 400, "height": 80},
"resource-id": "com.example:id/text_input",
"focused": "false",
"text": "Sample text",
"bounds": {"x": 100, "y": 200, "width": 300, "height": 50},
},
"children": [],
}
# ==============================================================================
# Cursor Placement & Boundary Alignment Tests
# ==============================================================================
class TestMoveCursorToEndIfBounds:
"""Test cases for move_cursor_to_end_if_bounds function."""
class TestCursorAlignment:
"""Validates precise cursor placement against interactive targets."""
@pytest.mark.asyncio
@patch("artemis.tools.utils.tap", new_callable=AsyncMock)
@patch("artemis.tools.utils.tap")
@patch("artemis.tools.utils.find_element_by_resource_id")
async def test_cursor_routes_to_end_via_resource_id(
def test_move_cursor_with_resource_id(
self,
mock_find_elem: MagicMock,
mock_tap_fn: AsyncMock,
dummy_context: MagicMock,
mock_graph_state: MagicMock,
sample_hierarchy_node: dict,
mock_find_element,
mock_tap,
mock_context,
mock_state,
sample_element,
):
"""Resource ID locator should calculate end-boundary tap (99% width/height offset)."""
mock_graph_state.latest_ui_hierarchy = [sample_hierarchy_node]
mock_find_elem.return_value = sample_hierarchy_node
"""Test moving cursor using resource_id (highest priority)."""
mock_state.latest_ui_hierarchy = [sample_element]
mock_find_element.return_value = sample_element
target = Target(resource_id="com.google.android.apps:id/search_query")
elem = await move_cursor_to_end_if_bounds(
ctx=dummy_context, state=mock_graph_state, target=target
target = Target(
resource_id="com.example:id/text_input",
resource_id_index=None,
text=None,
text_index=None,
bounds=None,
)
result = asyncio.run(
move_cursor_to_end_if_bounds(ctx=mock_context, state=mock_state, target=target)
)
mock_find_elem.assert_called_once_with(
ui_hierarchy=[sample_hierarchy_node],
resource_id="com.google.android.apps:id/search_query",
mock_find_element.assert_called_once_with(
ui_hierarchy=[sample_element],
resource_id="com.example:id/text_input",
index=0,
)
mock_tap_fn.assert_awaited_once()
kwargs = mock_tap_fn.await_args.kwargs
selector_req = kwargs["selector_request"]
assert isinstance(selector_req, SelectorRequestWithCoordinates)
assert selector_req.coordinates.x == 516
assert selector_req.coordinates.y == 329
assert elem == sample_hierarchy_node
mock_tap.assert_called_once()
call_args = mock_tap.call_args[1]
selector_request = call_args["selector_request"]
assert isinstance(selector_request, SelectorRequestWithCoordinates)
coords = selector_request.coordinates
assert coords.x == 397 # 100 + 300 * 0.99
assert coords.y == 249 # 200 + 50 * 0.99
assert result == sample_element
@pytest.mark.asyncio
@patch("artemis.tools.utils.tap", new_callable=AsyncMock)
@patch("artemis.tools.utils.tap")
@patch("artemis.tools.utils.find_element_by_resource_id")
async def test_cursor_routes_using_direct_bounds(
self,
mock_find_elem: MagicMock,
mock_tap_fn: AsyncMock,
dummy_context: MagicMock,
mock_graph_state: MagicMock,
def test_move_cursor_with_coordinates_only(
self, mock_find_element, mock_tap, mock_context, mock_state
):
"""Direct bounding box coordinates should bypass hierarchy inspection."""
bounds = ElementBounds(x=80, y=100, width=200, height=60)
target = Target(bounds=bounds)
result = await move_cursor_to_end_if_bounds(
ctx=dummy_context, state=mock_graph_state, target=target
"""Test moving cursor when only coordinates are provided."""
bounds = ElementBounds(x=50, y=150, width=200, height=40)
target = Target(
resource_id=None,
resource_id_index=None,
text=None,
text_index=None,
bounds=bounds,
)
mock_find_elem.assert_not_called()
mock_tap_fn.assert_awaited_once()
kwargs = mock_tap_fn.await_args.kwargs
selector_req = kwargs["selector_request"]
assert selector_req.coordinates.x == 278
assert selector_req.coordinates.y == 159
assert result is None
@pytest.mark.asyncio
@patch("artemis.tools.utils.tap", new_callable=AsyncMock)
@patch("artemis.tools.utils.find_element_by_text")
async def test_cursor_routes_by_matched_text(
self,
mock_find_text: MagicMock,
mock_tap_fn: AsyncMock,
dummy_context: MagicMock,
mock_graph_state: MagicMock,
sample_hierarchy_node: dict,
):
"""Visible text locator should successfully resolve bounds and trigger cursor placement."""
mock_graph_state.latest_ui_hierarchy = [sample_hierarchy_node]
mock_find_text.return_value = sample_hierarchy_node
target = Target(text="Search Google", text_index=0)
result = await move_cursor_to_end_if_bounds(
ctx=dummy_context, state=mock_graph_state, target=target
result = asyncio.run(
move_cursor_to_end_if_bounds(ctx=mock_context, state=mock_state, target=target)
)
mock_find_text.assert_called_once_with([sample_hierarchy_node], "Search Google", index=0)
mock_tap_fn.assert_awaited_once()
assert result == sample_hierarchy_node
mock_find_element.assert_not_called()
mock_tap.assert_called_once()
call_args = mock_tap.call_args[1]
selector_request = call_args["selector_request"]
coords = selector_request.coordinates
assert coords.x == 248 # 50 + 200 * 0.99
assert coords.y == 189 # 150 + 40 * 0.99
assert result is None # No element is returned when using coords directly
@pytest.mark.asyncio
@patch("artemis.tools.utils.tap", new_callable=AsyncMock)
@patch("artemis.tools.utils.tap")
@patch("artemis.tools.utils.find_element_by_text")
async def test_cursor_skips_when_text_unmatched(
self,
mock_find_text: MagicMock,
mock_tap_fn: AsyncMock,
dummy_context: MagicMock,
mock_graph_state: MagicMock,
def test_move_cursor_with_text_only_success(
self, mock_find_text, mock_tap, mock_context, mock_state, sample_element
):
"""Unresolved text target returns None without tapping."""
"""Test moving cursor when only text is provided and succeeds."""
mock_state.latest_ui_hierarchy = [sample_element]
mock_find_text.return_value = sample_element
target = Target(
resource_id=None,
resource_id_index=None,
text="Sample text",
text_index=0,
bounds=None,
)
result = asyncio.run(
move_cursor_to_end_if_bounds(ctx=mock_context, state=mock_state, target=target)
)
mock_find_text.assert_called_once_with([sample_element], "Sample text", index=0)
mock_tap.assert_called_once()
assert result == sample_element
@patch("artemis.tools.utils.tap")
@patch("artemis.tools.utils.find_element_by_text")
def test_move_cursor_with_text_only_element_not_found(
self, mock_find_text, mock_tap, mock_context, mock_state
):
"""Test when searching by text finds no element."""
mock_state.latest_ui_hierarchy = []
mock_find_text.return_value = None
target = Target(text="Unmatched Query")
result = await move_cursor_to_end_if_bounds(
ctx=dummy_context, state=mock_graph_state, target=target
target = Target(
resource_id=None,
resource_id_index=None,
text="Nonexistent text",
text_index=None,
bounds=None,
)
mock_tap_fn.assert_not_awaited()
result = asyncio.run(
move_cursor_to_end_if_bounds(ctx=mock_context, state=mock_state, target=target)
)
mock_tap.assert_not_called()
assert result is None
@patch("artemis.tools.utils.tap")
@patch("artemis.tools.utils.find_element_by_text")
def test_move_cursor_with_text_only_no_bounds(
self, mock_find_text, mock_tap, mock_context, mock_state
):
"""Test when element is found by text but has no bounds."""
element_no_bounds = {"text": "Text without bounds"}
mock_state.latest_ui_hierarchy = [element_no_bounds]
mock_find_text.return_value = element_no_bounds
target = Target(
resource_id=None,
resource_id_index=None,
text="Text without bounds",
text_index=None,
bounds=None,
)
result = asyncio.run(
move_cursor_to_end_if_bounds(ctx=mock_context, state=mock_state, target=target)
)
mock_tap.assert_not_called()
assert result is None # Should return None as no action was taken
@patch("artemis.tools.utils.find_element_by_resource_id")
def test_move_cursor_element_not_found_by_id(self, mock_find_element, mock_context, mock_state):
"""Test when element is not found by resource_id."""
mock_find_element.return_value = None
target = Target(
resource_id="com.example:id/nonexistent",
resource_id_index=None,
text=None,
text_index=None,
bounds=None,
)
result = asyncio.run(
move_cursor_to_end_if_bounds(ctx=mock_context, state=mock_state, target=target)
)
assert result is None
# ==============================================================================
# Element Focus Resolution Tests
# ==============================================================================
class TestFocusElementIfNeeded:
"""Test cases for focus_element_if_needed function."""
class TestElementFocusing:
"""Validates conditional focusing behavior prior to keyboard inputs."""
@pytest.mark.asyncio
@patch("artemis.tools.utils.tap", new_callable=AsyncMock)
@patch("artemis.tools.utils.UnifiedMobileController")
async def test_skips_tap_when_already_focused(
self,
mock_controller_cls: MagicMock,
mock_tap_fn: AsyncMock,
dummy_context: MagicMock,
sample_hierarchy_node: dict,
@patch("artemis.tools.utils.tap")
@patch("artemis.tools.utils.find_element_by_resource_id")
def test_focus_element_already_focused(
self, mock_find_element, mock_tap, mock_context, sample_rich_element
):
"""Elements reporting focused state true should avoid redundant tap interactions."""
focused_elem = sample_hierarchy_node.copy()
focused_elem["focused"] = "true"
"""Test when element is already focused."""
focused_element = sample_rich_element.copy()
focused_element["attributes"]["focused"] = "true"
mock_ctrl = MagicMock()
mock_ctrl.get_ui_elements = AsyncMock(return_value=[focused_elem])
mock_controller_cls.return_value = mock_ctrl
mock_response = Mock()
mock_response.json.return_value = {"elements": [focused_element]}
mock_context.ui_adb_client.get_screen_data = Mock(return_value=mock_response)
mock_find_element.return_value = focused_element["attributes"]
target = Target(resource_id="com.google.android.apps:id/search_query")
focus_strategy = await focus_element_if_needed(ctx=dummy_context, target=target)
target = Target(
resource_id="com.example:id/text_input",
resource_id_index=None,
text=None,
text_index=None,
bounds=None,
)
result = asyncio.run(focus_element_if_needed(ctx=mock_context, target=target))
mock_tap_fn.assert_not_awaited()
assert focus_strategy == "resource_id"
mock_tap.assert_not_called()
assert result == "resource_id"
mock_context.ui_adb_client.get_hierarchy.assert_called_once()
@pytest.mark.asyncio
@patch("artemis.tools.utils.tap", new_callable=AsyncMock)
@patch("artemis.tools.utils.UnifiedMobileController")
async def test_focus_executes_tap_for_unfocused_element(
self,
mock_controller_cls: MagicMock,
mock_tap_fn: AsyncMock,
dummy_context: MagicMock,
sample_hierarchy_node: dict,
@patch("artemis.tools.utils.tap")
@patch("artemis.tools.utils.find_element_by_resource_id")
def test_focus_element_needs_focus_success(
self, mock_find_element, mock_tap, mock_context, sample_rich_element
):
"""Unfocused element triggers focus tap and confirms state change."""
unfocused = sample_hierarchy_node.copy()
unfocused["focused"] = "false"
focused = sample_hierarchy_node.copy()
focused["focused"] = "true"
"""Test when element needs focus and focusing succeeds."""
unfocused_element = sample_rich_element
focused_element = {
"attributes": {
"resource-id": "com.example:id/text_input",
"focused": "true",
},
"children": [],
}
mock_ctrl = MagicMock()
mock_ctrl.get_ui_elements = AsyncMock(side_effect=[[unfocused], [focused]])
mock_controller_cls.return_value = mock_ctrl
mock_find_element.side_effect = [
unfocused_element["attributes"],
focused_element["attributes"],
]
target = Target(resource_id="com.google.android.apps:id/search_query")
strategy = await focus_element_if_needed(ctx=dummy_context, target=target)
target = Target(
resource_id="com.example:id/text_input",
resource_id_index=None,
text=None,
text_index=None,
bounds=None,
)
result = asyncio.run(focus_element_if_needed(ctx=mock_context, target=target))
mock_tap_fn.assert_awaited_once_with(
ctx=dummy_context,
selector_request=IdSelectorRequest(id="com.google.android.apps:id/search_query"),
mock_tap.assert_called_once_with(
ctx=mock_context,
selector_request=IdSelectorRequest(id="com.example:id/text_input"),
index=0,
)
assert strategy == "resource_id"
assert mock_context.ui_adb_client.get_hierarchy.call_count == 2
assert result == "resource_id"
@pytest.mark.asyncio
@patch("artemis.tools.utils.tap", new_callable=AsyncMock)
@patch("artemis.tools.utils.UnifiedMobileController")
async def test_focus_falls_back_to_text_coordinates(
self,
mock_controller_cls: MagicMock,
mock_tap_fn: AsyncMock,
dummy_context: MagicMock,
sample_hierarchy_node: dict,
):
"""When ID is unavailable, focusing falls back to text center tap coordinates."""
elem = sample_hierarchy_node.copy()
elem["bounds"] = {"x": 20, "y": 40, "width": 200, "height": 60}
mock_ctrl = MagicMock()
mock_ctrl.get_ui_elements = AsyncMock(return_value=[elem])
mock_controller_cls.return_value = mock_ctrl
target = Target(text="Search Google")
strategy = await focus_element_if_needed(ctx=dummy_context, target=target)
mock_tap_fn.assert_awaited_once()
req = mock_tap_fn.await_args.kwargs["selector_request"]
assert isinstance(req, SelectorRequestWithCoordinates)
assert req.coordinates.x == 120 # 20 + 200/2
assert req.coordinates.y == 70 # 40 + 60/2
assert strategy == "text"
@pytest.mark.asyncio
@patch("artemis.tools.utils.tap")
@patch("artemis.tools.utils.logger")
@patch("artemis.tools.utils.UnifiedMobileController")
async def test_focus_aborts_when_no_locators_resolve(
@patch("artemis.tools.utils.find_element_by_resource_id")
def test_focus_id_and_text_mismatch_fallback_to_text(
self,
mock_controller_cls: MagicMock,
mock_logger: MagicMock,
dummy_context: MagicMock,
mock_find_id,
mock_logger,
mock_tap,
mock_context,
sample_rich_element,
):
"""When none of the declared locators match an active element, returns None and logs error."""
mock_ctrl = MagicMock()
mock_ctrl.get_ui_elements = AsyncMock(return_value=[])
mock_controller_cls.return_value = mock_ctrl
"""Test fallback when resource_id and text point to different elements."""
element_from_id = sample_rich_element["attributes"].copy()
element_from_id["text"] = "Different text"
target = Target(resource_id="unknown_id", text="unknown_text")
strategy = await focus_element_if_needed(ctx=dummy_context, target=target)
element_from_text = sample_rich_element.copy()
element_from_text["attributes"]["bounds"] = {
"x": 10,
"y": 20,
"width": 100,
"height": 30,
}
mock_logger.error.assert_called_once()
assert strategy is None
mock_response = Mock()
mock_response.json.return_value = {"elements": [element_from_text]}
mock_context.ui_adb_client.get_screen_data = Mock(return_value=mock_response)
mock_find_id.return_value = element_from_id
with patch("artemis.tools.utils.find_element_by_text") as mock_find_text:
mock_find_text.return_value = element_from_text["attributes"]
# ==============================================================================
# Helper Verification & Selector Validation Tests
# ==============================================================================
target = Target(
resource_id="com.example:id/text_input",
resource_id_index=None,
text="Sample text",
text_index=None,
bounds=None,
)
result = asyncio.run(focus_element_if_needed(ctx=mock_context, target=target))
mock_logger.warning.assert_called_once()
mock_tap.assert_called_once()
assert result == "text"
class TestSelectorValidation:
"""Verifies target completeness and boundary constraint checks."""
@patch("artemis.tools.utils.tap")
@patch("artemis.tools.utils.find_element_by_text")
def test_focus_fallback_to_text(
self, mock_find_text, mock_tap, mock_context, sample_rich_element
):
"""Test fallback to focusing using text."""
element_with_bounds = sample_rich_element.copy()
element_with_bounds["attributes"]["bounds"] = {
"x": 10,
"y": 20,
"width": 100,
"height": 30,
}
def test_has_valid_selectors_matrix(self):
"""Verifies selector detection across valid and invalid combinations."""
assert has_valid_selectors(Target(resource_id="elem_id"))
assert has_valid_selectors(Target(text="Label"))
assert has_valid_selectors(Target(bounds=ElementBounds(x=0, y=0, width=10, height=10)))
assert not has_valid_selectors(Target())
mock_response = Mock()
mock_response.json.return_value = {"elements": [element_with_bounds]}
mock_context.ui_adb_client.get_screen_data = Mock(return_value=mock_response)
mock_find_text.return_value = element_with_bounds["attributes"]
def test_validate_coordinates_bounds(self):
"""Checks bounds containment against physical device display limits."""
valid_target = Target(bounds=ElementBounds(x=100, y=200, width=100, height=100))
assert validate_coordinates_bounds(valid_target, 1080, 2400) is None
out_width = Target(bounds=ElementBounds(x=1050, y=200, width=100, height=100))
assert validate_coordinates_bounds(out_width, 1080, 2400) is not None
out_height = Target(bounds=ElementBounds(x=100, y=2380, width=100, height=100))
assert validate_coordinates_bounds(out_height, 1080, 2400) is not None
def test_extract_selector_attributes(self):
"""Extracts resource ID and text from heterogeneous selector requests."""
req_id = IdSelectorRequest(id="view_item")
assert _extract_resource_id_and_text_from_selector(req_id) == ("view_item", None)
req_text = TextSelectorRequest(text="Confirm")
assert _extract_resource_id_and_text_from_selector(req_text) == (None, "Confirm")
req_both = IdWithTextSelectorRequest(id="submit_btn", text="Submit")
assert _extract_resource_id_and_text_from_selector(req_both) == ("submit_btn", "Submit")
req_coords = SelectorRequestWithCoordinates(
coordinates=CoordinatesSelectorRequest(x=50, y=50)
target = Target(
resource_id=None,
resource_id_index=None,
text="Sample text",
text_index=None,
bounds=None,
)
assert _extract_resource_id_and_text_from_selector(req_coords) == (None, None)
result = asyncio.run(focus_element_if_needed(ctx=mock_context, target=target))
mock_find_text.assert_called_once()
mock_tap.assert_called_once()
call_args = mock_tap.call_args[1]
selector = call_args["selector_request"]
assert isinstance(selector, SelectorRequestWithCoordinates)
assert selector.coordinates.x == 60 # 10 + 100/2
assert selector.coordinates.y == 35 # 20 + 30/2
assert result == "text"
@patch("artemis.tools.utils.logger")
def test_focus_all_locators_fail(self, mock_logger, mock_context):
"""Test failure when no locator can find an element."""
mock_response = Mock()
mock_response.json.return_value = {"elements": []}
mock_context.ui_adb_client.get_screen_data = Mock(return_value=mock_response)
with (
patch("artemis.tools.utils.find_element_by_resource_id") as mock_find_id,
patch("artemis.tools.utils.find_element_by_text") as mock_find_text,
):
mock_find_id.return_value = None
mock_find_text.return_value = None
target = Target(
resource_id="nonexistent",
resource_id_index=None,
text="nonexistent",
text_index=None,
bounds=None,
)
result = asyncio.run(focus_element_if_needed(ctx=mock_context, target=target))
mock_logger.error.assert_called_once_with(
"Failed to focus element."
+ " No valid locator (resource_id, coordinates, or text) succeeded."
)
assert result is None
if __name__ == "__main__":
pytest.main([__file__])