refactor(core): consolidate action and plan definitions
This commit is contained in:
1 parent
98ee55f5d2
commit
a4c28865f2
17 files changed
+481
-1878
No files matched your search
@@ -1,148 +0,0 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from artemis.context import ArtemisContext
|
||||
from artemis.drivers.base import BaseDeviceDriver
|
||||
from artemis.graph.state import State
|
||||
from artemis.tools.base import ArtemisTool, ToolRegistry
|
||||
from artemis.tools.mobile.exec_tools import (
|
||||
Click,
|
||||
ClickArgs,
|
||||
ClickTool,
|
||||
click,
|
||||
click_wrapper,
|
||||
get_click_tool,
|
||||
)
|
||||
from langchain_core.messages import ToolMessage
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ctx():
|
||||
ctx = MagicMock(spec=ArtemisContext)
|
||||
ctx.device = MagicMock()
|
||||
ctx.device.device_width = 1080
|
||||
ctx.device.device_height = 2400
|
||||
return ctx
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_driver():
|
||||
driver = MagicMock(spec=BaseDeviceDriver)
|
||||
driver.screen_size = (1080, 2400)
|
||||
driver.tap = AsyncMock(return_value=True)
|
||||
return driver
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_state():
|
||||
return MagicMock(spec=State)
|
||||
|
||||
|
||||
def test_click_tool_subclass_and_registry():
|
||||
"""Verify ClickTool is a subclass of ArtemisTool and properly registered."""
|
||||
assert issubclass(ClickTool, ArtemisTool)
|
||||
assert issubclass(Click, ArtemisTool)
|
||||
assert isinstance(click, ArtemisTool)
|
||||
assert isinstance(click, ClickTool)
|
||||
|
||||
assert click.name == "click"
|
||||
assert click.category == "action"
|
||||
assert click.args_schema == ClickArgs
|
||||
|
||||
# Registry lookup
|
||||
reg_tool = ToolRegistry.get("click")
|
||||
assert reg_tool is not None
|
||||
assert isinstance(reg_tool, ClickTool)
|
||||
|
||||
# GenAI FunctionDeclaration export
|
||||
declaration = click.to_genai_declaration()
|
||||
assert declaration.name == "click"
|
||||
assert "target" in declaration.parameters.properties
|
||||
|
||||
# Wrapper check
|
||||
assert click_wrapper is not None
|
||||
assert click_wrapper.tool_fn_getter == get_click_tool
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_click_direct_execution_with_driver(mock_driver):
|
||||
"""Verify direct execution of ClickTool with BaseDeviceDriver."""
|
||||
result = await click.execute(driver=mock_driver, target=[500, 500])
|
||||
mock_driver.tap.assert_called_once_with(540, 1200)
|
||||
assert "Clicked at [500, 500] (normalized) successfully." in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_click_direct_execution_with_ctx(mock_ctx):
|
||||
"""Verify direct execution of ClickTool with ArtemisContext."""
|
||||
with patch("artemis.tools.mobile.exec_tools.UnifiedMobileController") as mock_controller_cls:
|
||||
mock_controller_inst = MagicMock()
|
||||
mock_controller_inst.tap_at = AsyncMock(return_value=MagicMock(error=None))
|
||||
mock_controller_cls.return_value = mock_controller_inst
|
||||
|
||||
result = await click.execute(ctx=mock_ctx, target=[250, 750])
|
||||
mock_controller_inst.tap_at.assert_called_once_with(270, 1800)
|
||||
assert "Clicked at [250, 750] (normalized) successfully." in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_click_with_state_command(mock_ctx, mock_state):
|
||||
"""Verify ClickTool returns ToolMessage when state is provided."""
|
||||
with patch("artemis.tools.mobile.exec_tools.UnifiedMobileController") as mock_controller_cls:
|
||||
mock_controller_inst = MagicMock()
|
||||
mock_controller_inst.tap_at = AsyncMock(return_value=MagicMock(error=None))
|
||||
mock_controller_cls.return_value = mock_controller_inst
|
||||
|
||||
cmd = await click.execute(
|
||||
ctx=mock_ctx,
|
||||
target=[100, 200],
|
||||
tool_call_id="call_click_1",
|
||||
state=mock_state,
|
||||
)
|
||||
assert isinstance(cmd, ToolMessage)
|
||||
assert cmd.tool_call_id == "call_click_1"
|
||||
assert cmd.status == "success"
|
||||
assert "Clicked at [100, 200]" in cmd.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_click_callable_execution(mock_driver):
|
||||
"""Verify invoking click directly as a callable."""
|
||||
result = await click(driver=mock_driver, target=[100, 100])
|
||||
assert "Clicked at [100, 100] (normalized) successfully." in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_click_invalid_target(mock_ctx):
|
||||
"""Verify error handling on invalid target coordinates."""
|
||||
result = await click.execute(ctx=mock_ctx, target=[])
|
||||
assert "Error during click: Invalid target coordinates" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_click_tool_langchain_ainvoke(mock_ctx):
|
||||
"""Verify get_click_tool exports a LangChain tool that works with ainvoke."""
|
||||
click_tool = get_click_tool(mock_ctx)
|
||||
assert click_tool.name == "click"
|
||||
|
||||
with patch("artemis.tools.mobile.exec_tools.UnifiedMobileController") as mock_controller_cls:
|
||||
mock_controller_inst = MagicMock()
|
||||
mock_controller_inst.tap_at = AsyncMock(return_value=MagicMock(error=None))
|
||||
mock_controller_cls.return_value = mock_controller_inst
|
||||
|
||||
result = await click_tool.ainvoke({"target": [500, 500]})
|
||||
assert "Clicked at [500, 500] (normalized) successfully." in result
|
||||
@@ -1,172 +0,0 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from artemis.context import ArtemisContext
|
||||
from artemis.drivers.base import BaseDeviceDriver
|
||||
from artemis.graph.state import State
|
||||
from artemis.tools.base import ArtemisTool, ToolRegistry
|
||||
from artemis.tools.mobile.exec_tools import (
|
||||
InputText,
|
||||
InputTextArgs,
|
||||
InputTextTool,
|
||||
get_input_text_tool,
|
||||
input_text,
|
||||
input_text_wrapper,
|
||||
)
|
||||
from langchain_core.messages import ToolMessage
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ctx():
|
||||
ctx = MagicMock(spec=ArtemisContext)
|
||||
ctx.device = MagicMock()
|
||||
ctx.device.device_width = 1080
|
||||
ctx.device.device_height = 2400
|
||||
return ctx
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_driver():
|
||||
driver = MagicMock(spec=BaseDeviceDriver)
|
||||
driver.screen_size = (1080, 2400)
|
||||
driver.tap = AsyncMock(return_value=True)
|
||||
driver.input_text = AsyncMock(return_value=True)
|
||||
return driver
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_state():
|
||||
return MagicMock(spec=State)
|
||||
|
||||
|
||||
def test_input_text_tool_subclass_and_registry():
|
||||
"""Verify InputTextTool is a subclass of ArtemisTool and properly registered."""
|
||||
assert issubclass(InputTextTool, ArtemisTool)
|
||||
assert issubclass(InputText, ArtemisTool)
|
||||
assert isinstance(input_text, ArtemisTool)
|
||||
assert isinstance(input_text, InputTextTool)
|
||||
|
||||
assert input_text.name == "input_text"
|
||||
assert input_text.category == "action"
|
||||
assert input_text.args_schema == InputTextArgs
|
||||
|
||||
# Registry lookup
|
||||
reg_tool = ToolRegistry.get("input_text")
|
||||
assert reg_tool is not None
|
||||
assert isinstance(reg_tool, InputTextTool)
|
||||
|
||||
# GenAI FunctionDeclaration export
|
||||
declaration = input_text.to_genai_declaration()
|
||||
assert declaration.name == "input_text"
|
||||
assert "text" in declaration.parameters.properties
|
||||
assert "target" in declaration.parameters.properties
|
||||
assert "clear_exist" in declaration.parameters.properties
|
||||
|
||||
# Wrapper check
|
||||
assert input_text_wrapper is not None
|
||||
assert input_text_wrapper.tool_fn_getter == get_input_text_tool
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_input_text_direct_execution_with_driver(mock_driver):
|
||||
"""Verify direct execution of InputTextTool with BaseDeviceDriver."""
|
||||
result = await input_text.execute(
|
||||
driver=mock_driver, text="hello world", target=[500, 500], clear_exist=True
|
||||
)
|
||||
mock_driver.tap.assert_called_once_with(540, 1200)
|
||||
mock_driver.input_text.assert_called_once_with("hello world", clear_existing=True)
|
||||
assert "Typed 'hello world' successfully." in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_input_text_direct_execution_with_ctx(mock_ctx):
|
||||
"""Verify direct execution of InputTextTool with ArtemisContext."""
|
||||
with patch("artemis.tools.mobile.exec_tools.UnifiedMobileController") as mock_controller_cls:
|
||||
mock_controller_inst = MagicMock()
|
||||
mock_controller_inst.tap_at = AsyncMock(return_value=MagicMock(error=None))
|
||||
mock_controller_inst.erase_text = AsyncMock(return_value=True)
|
||||
mock_controller_inst.type_text = AsyncMock(return_value=True)
|
||||
mock_controller_cls.return_value = mock_controller_inst
|
||||
|
||||
result = await input_text.execute(
|
||||
ctx=mock_ctx, text="test input", target=[250, 750], clear_exist=True
|
||||
)
|
||||
mock_controller_inst.tap_at.assert_called_once_with(270, 1800)
|
||||
mock_controller_inst.erase_text.assert_called_once()
|
||||
mock_controller_inst.type_text.assert_called_once_with("test input", clear_existing=False)
|
||||
assert "Typed 'test input' successfully." in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_input_text_with_state_command(mock_ctx, mock_state):
|
||||
"""Verify InputTextTool returns ToolMessage when state is provided."""
|
||||
with patch("artemis.tools.mobile.exec_tools.UnifiedMobileController") as mock_controller_cls:
|
||||
mock_controller_inst = MagicMock()
|
||||
mock_controller_inst.tap_at = AsyncMock(return_value=MagicMock(error=None))
|
||||
mock_controller_inst.erase_text = AsyncMock(return_value=True)
|
||||
mock_controller_inst.type_text = AsyncMock(return_value=True)
|
||||
mock_controller_cls.return_value = mock_controller_inst
|
||||
|
||||
cmd = await input_text.execute(
|
||||
ctx=mock_ctx,
|
||||
text="query",
|
||||
target=[100, 200],
|
||||
tool_call_id="call_it_1",
|
||||
state=mock_state,
|
||||
)
|
||||
assert isinstance(cmd, ToolMessage)
|
||||
assert cmd.tool_call_id == "call_it_1"
|
||||
assert cmd.status == "success"
|
||||
assert "Typed 'query'" in cmd.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_input_text_callable_execution(mock_driver):
|
||||
"""Verify invoking input_text directly as a callable."""
|
||||
result = await input_text(driver=mock_driver, text="call text", target=[100, 100])
|
||||
assert "Typed 'call text' successfully." in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_input_text_invalid_target(mock_ctx):
|
||||
"""Verify error handling on invalid target coordinates."""
|
||||
result = await input_text.execute(ctx=mock_ctx, text="bad", target=[])
|
||||
assert "Error during input text: Invalid target coordinates" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_input_text_tool_langchain_ainvoke(mock_ctx):
|
||||
"""Verify get_input_text_tool exports a LangChain tool that works with ainvoke."""
|
||||
it_tool = get_input_text_tool(mock_ctx)
|
||||
assert it_tool.name == "input_text"
|
||||
|
||||
with patch("artemis.tools.mobile.exec_tools.UnifiedMobileController") as mock_controller_cls:
|
||||
mock_controller_inst = MagicMock()
|
||||
mock_controller_inst.tap_at = AsyncMock(return_value=MagicMock(error=None))
|
||||
mock_controller_inst.press_key = AsyncMock(return_value=True)
|
||||
mock_controller_inst.type_text = AsyncMock(return_value=True)
|
||||
mock_controller_cls.return_value = mock_controller_inst
|
||||
|
||||
result = await it_tool.ainvoke(
|
||||
{
|
||||
"text": "abc",
|
||||
"target": [500, 500],
|
||||
"clear_exist": False,
|
||||
}
|
||||
)
|
||||
mock_controller_inst.press_key.assert_called_once_with("123")
|
||||
assert "Typed 'abc' successfully." in result
|
||||
@@ -1,152 +0,0 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from artemis.context import ArtemisContext
|
||||
from artemis.drivers.base import BaseDeviceDriver
|
||||
from artemis.graph.state import State
|
||||
from artemis.tools.base import ArtemisTool, ToolRegistry
|
||||
from artemis.tools.mobile.exec_tools import (
|
||||
LongPress,
|
||||
LongPressArgs,
|
||||
LongPressTool,
|
||||
get_long_press_tool,
|
||||
long_press,
|
||||
long_press_wrapper,
|
||||
)
|
||||
from langchain_core.messages import ToolMessage
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ctx():
|
||||
ctx = MagicMock(spec=ArtemisContext)
|
||||
ctx.device = MagicMock()
|
||||
ctx.device.device_width = 1080
|
||||
ctx.device.device_height = 2400
|
||||
return ctx
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_driver():
|
||||
driver = MagicMock(spec=BaseDeviceDriver)
|
||||
driver.screen_size = (1080, 2400)
|
||||
driver.long_press = AsyncMock(return_value=True)
|
||||
return driver
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_state():
|
||||
return MagicMock(spec=State)
|
||||
|
||||
|
||||
def test_long_press_tool_subclass_and_registry():
|
||||
"""Verify LongPressTool is a subclass of ArtemisTool and properly registered."""
|
||||
assert issubclass(LongPressTool, ArtemisTool)
|
||||
assert issubclass(LongPress, ArtemisTool)
|
||||
assert isinstance(long_press, ArtemisTool)
|
||||
assert isinstance(long_press, LongPressTool)
|
||||
|
||||
assert long_press.name == "long_press"
|
||||
assert long_press.category == "action"
|
||||
assert long_press.args_schema == LongPressArgs
|
||||
|
||||
# Registry lookup
|
||||
reg_tool = ToolRegistry.get("long_press")
|
||||
assert reg_tool is not None
|
||||
assert isinstance(reg_tool, LongPressTool)
|
||||
|
||||
# GenAI FunctionDeclaration export
|
||||
declaration = long_press.to_genai_declaration()
|
||||
assert declaration.name == "long_press"
|
||||
assert "target" in declaration.parameters.properties
|
||||
assert "duration" in declaration.parameters.properties
|
||||
|
||||
# Wrapper check
|
||||
assert long_press_wrapper is not None
|
||||
assert long_press_wrapper.tool_fn_getter == get_long_press_tool
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_long_press_direct_execution_with_driver(mock_driver):
|
||||
"""Verify direct execution of LongPressTool with BaseDeviceDriver."""
|
||||
result = await long_press.execute(driver=mock_driver, target=[500, 500], duration=1500)
|
||||
mock_driver.long_press.assert_called_once_with(540, 1200, duration_ms=1500)
|
||||
assert "Long pressed at [500, 500] (normalized) successfully." in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_long_press_direct_execution_with_ctx(mock_ctx):
|
||||
"""Verify direct execution of LongPressTool with ArtemisContext."""
|
||||
with patch("artemis.tools.mobile.exec_tools.UnifiedMobileController") as mock_controller_cls:
|
||||
mock_controller_inst = MagicMock()
|
||||
mock_controller_inst.tap_at = AsyncMock(return_value=MagicMock(error=None))
|
||||
mock_controller_cls.return_value = mock_controller_inst
|
||||
|
||||
result = await long_press.execute(ctx=mock_ctx, target=[250, 750], duration=2000)
|
||||
mock_controller_inst.tap_at.assert_called_once_with(
|
||||
270, 1800, long_press=True, long_press_duration=2000
|
||||
)
|
||||
assert "Long pressed at [250, 750] (normalized) successfully." in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_long_press_with_state_command(mock_ctx, mock_state):
|
||||
"""Verify LongPressTool returns ToolMessage when state is provided."""
|
||||
with patch("artemis.tools.mobile.exec_tools.UnifiedMobileController") as mock_controller_cls:
|
||||
mock_controller_inst = MagicMock()
|
||||
mock_controller_inst.tap_at = AsyncMock(return_value=MagicMock(error=None))
|
||||
mock_controller_cls.return_value = mock_controller_inst
|
||||
|
||||
cmd = await long_press.execute(
|
||||
ctx=mock_ctx,
|
||||
target=[100, 200],
|
||||
duration=1200,
|
||||
tool_call_id="call_lp_1",
|
||||
state=mock_state,
|
||||
)
|
||||
assert isinstance(cmd, ToolMessage)
|
||||
assert cmd.tool_call_id == "call_lp_1"
|
||||
assert cmd.status == "success"
|
||||
assert "Long pressed at [100, 200]" in cmd.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_long_press_callable_execution(mock_driver):
|
||||
"""Verify invoking long_press directly as a callable."""
|
||||
result = await long_press(driver=mock_driver, target=[100, 100])
|
||||
assert "Long pressed at [100, 100] (normalized) successfully." in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_long_press_invalid_target(mock_ctx):
|
||||
"""Verify error handling on invalid target coordinates."""
|
||||
result = await long_press.execute(ctx=mock_ctx, target=[])
|
||||
assert "Error during long press: Invalid target coordinates" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_long_press_tool_langchain_ainvoke(mock_ctx):
|
||||
"""Verify get_long_press_tool exports a LangChain tool that works with ainvoke."""
|
||||
lp_tool = get_long_press_tool(mock_ctx)
|
||||
assert lp_tool.name == "long_press"
|
||||
|
||||
with patch("artemis.tools.mobile.exec_tools.UnifiedMobileController") as mock_controller_cls:
|
||||
mock_controller_inst = MagicMock()
|
||||
mock_controller_inst.tap_at = AsyncMock(return_value=MagicMock(error=None))
|
||||
mock_controller_cls.return_value = mock_controller_inst
|
||||
|
||||
result = await lp_tool.ainvoke({"target": [500, 500], "duration": 1500})
|
||||
assert "Long pressed at [500, 500] (normalized) successfully." in result
|
||||
@@ -1,164 +0,0 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from artemis.context import ArtemisContext
|
||||
from artemis.drivers.base import BaseDeviceDriver
|
||||
from artemis.graph.state import State
|
||||
from artemis.tools.base import ArtemisTool, ToolRegistry
|
||||
from artemis.tools.mobile.exec_tools import (
|
||||
PressKey,
|
||||
PressKeyArgs,
|
||||
PressKeyTool,
|
||||
get_press_key_tool,
|
||||
press_key,
|
||||
press_key_wrapper,
|
||||
)
|
||||
from langchain_core.messages import ToolMessage
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ctx():
|
||||
ctx = MagicMock(spec=ArtemisContext)
|
||||
ctx.device = MagicMock()
|
||||
return ctx
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_driver():
|
||||
driver = MagicMock(spec=BaseDeviceDriver)
|
||||
driver.press_key = AsyncMock(return_value=True)
|
||||
return driver
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_state():
|
||||
return MagicMock(spec=State)
|
||||
|
||||
|
||||
def test_press_key_tool_subclass_and_registry():
|
||||
"""Verify PressKeyTool is a subclass of ArtemisTool and properly registered."""
|
||||
assert issubclass(PressKeyTool, ArtemisTool)
|
||||
assert issubclass(PressKey, ArtemisTool)
|
||||
assert isinstance(press_key, ArtemisTool)
|
||||
assert isinstance(press_key, PressKeyTool)
|
||||
|
||||
assert press_key.name == "press_key"
|
||||
assert press_key.category == "action"
|
||||
assert press_key.args_schema == PressKeyArgs
|
||||
|
||||
# Registry lookup
|
||||
reg_tool = ToolRegistry.get("press_key")
|
||||
assert reg_tool is not None
|
||||
assert isinstance(reg_tool, PressKeyTool)
|
||||
|
||||
# GenAI FunctionDeclaration export
|
||||
declaration = press_key.to_genai_declaration()
|
||||
assert declaration.name == "press_key"
|
||||
assert "key" in declaration.parameters.properties
|
||||
|
||||
# Wrapper check
|
||||
assert press_key_wrapper is not None
|
||||
assert press_key_wrapper.tool_fn_getter == get_press_key_tool
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_press_key_direct_execution_with_driver(mock_driver):
|
||||
"""Verify direct execution with BaseDeviceDriver."""
|
||||
result = await press_key.execute(driver=mock_driver, key="BACK")
|
||||
mock_driver.press_key.assert_called_once_with("BACK")
|
||||
assert "Pressed key 'BACK' successfully." in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_press_key_direct_execution_with_ctx(mock_ctx):
|
||||
"""Verify direct execution with ArtemisContext for various keys."""
|
||||
with patch("artemis.tools.mobile.exec_tools.UnifiedMobileController") as mock_controller_cls:
|
||||
mock_controller_inst = MagicMock()
|
||||
mock_controller_inst.press_enter = AsyncMock(return_value=True)
|
||||
mock_controller_inst.go_back = AsyncMock(return_value=True)
|
||||
mock_controller_inst.go_home = AsyncMock(return_value=True)
|
||||
mock_controller_inst.press_key = AsyncMock(return_value=True)
|
||||
mock_controller_cls.return_value = mock_controller_inst
|
||||
|
||||
# Test ENTER
|
||||
res_enter = await press_key.execute(ctx=mock_ctx, key="ENTER")
|
||||
mock_controller_inst.press_enter.assert_called_once()
|
||||
assert "Pressed key 'ENTER' successfully." in res_enter
|
||||
|
||||
# Test BACK
|
||||
res_back = await press_key.execute(ctx=mock_ctx, key="BACK")
|
||||
mock_controller_inst.go_back.assert_called_once()
|
||||
assert "Pressed key 'BACK' successfully." in res_back
|
||||
|
||||
# Test HOME
|
||||
res_home = await press_key.execute(ctx=mock_ctx, key="HOME")
|
||||
mock_controller_inst.go_home.assert_called_once()
|
||||
assert "Pressed key 'HOME' successfully." in res_home
|
||||
|
||||
# Test APP_SWITCH
|
||||
res_app_switch = await press_key.execute(ctx=mock_ctx, key="APP_SWITCH")
|
||||
mock_controller_inst.press_key.assert_called_with("KEYCODE_APP_SWITCH")
|
||||
assert "Pressed key 'APP_SWITCH' successfully." in res_app_switch
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_press_key_with_state_command(mock_ctx, mock_state):
|
||||
"""Verify PressKeyTool returns ToolMessage when state is provided."""
|
||||
with patch("artemis.tools.mobile.exec_tools.UnifiedMobileController") as mock_controller_cls:
|
||||
mock_controller_inst = MagicMock()
|
||||
mock_controller_inst.go_home = AsyncMock(return_value=True)
|
||||
mock_controller_cls.return_value = mock_controller_inst
|
||||
|
||||
cmd = await press_key.execute(
|
||||
ctx=mock_ctx,
|
||||
key="HOME",
|
||||
tool_call_id="call_pk_1",
|
||||
state=mock_state,
|
||||
)
|
||||
assert isinstance(cmd, ToolMessage)
|
||||
assert cmd.tool_call_id == "call_pk_1"
|
||||
assert cmd.status == "success"
|
||||
assert "Pressed key 'HOME' successfully." in cmd.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_press_key_callable_execution(mock_driver):
|
||||
"""Verify invoking press_key directly as a callable."""
|
||||
result = await press_key(driver=mock_driver, key="ENTER")
|
||||
assert "Pressed key 'ENTER' successfully." in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_press_key_missing_key(mock_ctx):
|
||||
"""Verify error handling when key is empty or missing."""
|
||||
result = await press_key.execute(ctx=mock_ctx, key="")
|
||||
assert "Error during press key: Key parameter is required." in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_press_key_tool_langchain_ainvoke(mock_ctx):
|
||||
"""Verify get_press_key_tool exports a LangChain tool that works with ainvoke."""
|
||||
pk_tool = get_press_key_tool(mock_ctx)
|
||||
assert pk_tool.name == "press_key"
|
||||
|
||||
with patch("artemis.tools.mobile.exec_tools.UnifiedMobileController") as mock_controller_cls:
|
||||
mock_controller_inst = MagicMock()
|
||||
mock_controller_inst.go_back = AsyncMock(return_value=True)
|
||||
mock_controller_cls.return_value = mock_controller_inst
|
||||
|
||||
result = await pk_tool.ainvoke({"key": "BACK"})
|
||||
assert "Pressed key 'BACK' successfully." in result
|
||||
@@ -1,316 +0,0 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from artemis.context import ArtemisContext
|
||||
from artemis.drivers.base import BaseDeviceDriver
|
||||
from artemis.graph.state import State
|
||||
from artemis.tools.base import ArtemisTool, ToolRegistry
|
||||
from artemis.tools.mobile.exec_tools import (
|
||||
Swipe,
|
||||
SwipeArgs,
|
||||
SwipeTool,
|
||||
get_swipe_tool,
|
||||
swipe,
|
||||
swipe_wrapper,
|
||||
)
|
||||
from langchain_core.messages import ToolMessage
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ctx():
|
||||
ctx = MagicMock(spec=ArtemisContext)
|
||||
ctx.device = MagicMock()
|
||||
ctx.device.device_width = 1080
|
||||
ctx.device.device_height = 2400
|
||||
return ctx
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_driver():
|
||||
driver = MagicMock(spec=BaseDeviceDriver)
|
||||
driver.screen_size = (1080, 2400)
|
||||
driver.swipe_direction = AsyncMock(return_value=True)
|
||||
driver.swipe = AsyncMock(return_value=True)
|
||||
return driver
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_state():
|
||||
return MagicMock(spec=State)
|
||||
|
||||
|
||||
def test_swipe_tool_subclass_and_registry():
|
||||
"""Verify SwipeTool is a subclass of ArtemisTool and properly registered."""
|
||||
assert issubclass(SwipeTool, ArtemisTool)
|
||||
assert issubclass(Swipe, ArtemisTool)
|
||||
assert isinstance(swipe, ArtemisTool)
|
||||
assert isinstance(swipe, SwipeTool)
|
||||
|
||||
assert swipe.name == "swipe"
|
||||
assert swipe.category == "action"
|
||||
assert swipe.args_schema == SwipeArgs
|
||||
|
||||
# Registry lookup
|
||||
reg_tool = ToolRegistry.get("swipe")
|
||||
assert reg_tool is not None
|
||||
assert isinstance(reg_tool, ArtemisTool)
|
||||
|
||||
# GenAI FunctionDeclaration export
|
||||
declaration = swipe.to_genai_declaration()
|
||||
assert declaration.name == "swipe"
|
||||
assert "action" in declaration.parameters.properties
|
||||
assert "duration" in declaration.parameters.properties
|
||||
|
||||
# Wrapper check
|
||||
assert swipe_wrapper is not None
|
||||
assert swipe_wrapper.tool_fn_getter == get_swipe_tool
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_swipe_direct_execution_with_driver_direction(mock_driver):
|
||||
"""Verify direct execution with direction on BaseDeviceDriver."""
|
||||
result = await swipe.execute(driver=mock_driver, action="up", duration=500)
|
||||
mock_driver.swipe_direction.assert_called_once_with("up", duration_ms=500)
|
||||
assert "Swipe completed successfully." in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_swipe_direct_execution_with_driver_coords(mock_driver):
|
||||
"""Verify direct execution with coordinates on BaseDeviceDriver."""
|
||||
result = await swipe.execute(driver=mock_driver, action=[100, 200, 300, 400], duration=600)
|
||||
mock_driver.swipe.assert_called_once_with(108, 480, 324, 960, duration_ms=600)
|
||||
assert "Swipe completed successfully." in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_swipe_direct_execution_with_ctx_direction(mock_ctx):
|
||||
"""Verify direct execution with direction on ArtemisContext."""
|
||||
with patch("artemis.tools.mobile.exec_tools.UnifiedMobileController") as mock_controller_cls:
|
||||
mock_controller_inst = MagicMock()
|
||||
mock_controller_inst.swipe_coords = AsyncMock(return_value=None)
|
||||
mock_controller_cls.return_value = mock_controller_inst
|
||||
|
||||
result = await swipe.execute(ctx=mock_ctx, action="down", duration=400)
|
||||
mock_controller_inst.swipe_coords.assert_called_once_with(648, 720, 648, 1680, 400)
|
||||
assert "Swipe completed successfully." in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_swipe_direct_execution_with_ctx_coords(mock_ctx):
|
||||
"""Verify direct execution with coordinates on ArtemisContext."""
|
||||
with patch("artemis.tools.mobile.exec_tools.UnifiedMobileController") as mock_controller_cls:
|
||||
mock_controller_inst = MagicMock()
|
||||
mock_controller_inst.swipe_coords = AsyncMock(return_value=None)
|
||||
mock_controller_cls.return_value = mock_controller_inst
|
||||
|
||||
result = await swipe.execute(ctx=mock_ctx, action=[200, 300, 800, 900], duration=800)
|
||||
mock_controller_inst.swipe_coords.assert_called_once_with(216, 720, 864, 2160, 800)
|
||||
assert "Swipe completed successfully." in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_swipe_with_state_command(mock_ctx, mock_state):
|
||||
"""Verify SwipeTool returns ToolMessage when state is provided."""
|
||||
with patch("artemis.tools.mobile.exec_tools.UnifiedMobileController") as mock_controller_cls:
|
||||
mock_controller_inst = MagicMock()
|
||||
mock_controller_inst.swipe_coords = AsyncMock(return_value=None)
|
||||
mock_controller_cls.return_value = mock_controller_inst
|
||||
|
||||
cmd = await swipe.execute(
|
||||
ctx=mock_ctx,
|
||||
action="left",
|
||||
tool_call_id="call_sw_1",
|
||||
state=mock_state,
|
||||
)
|
||||
assert isinstance(cmd, ToolMessage)
|
||||
assert cmd.tool_call_id == "call_sw_1"
|
||||
assert cmd.status == "success"
|
||||
assert "Swipe completed successfully." in cmd.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_swipe_callable_execution(mock_driver):
|
||||
"""Verify invoking swipe directly as a callable."""
|
||||
result = await swipe(driver=mock_driver, action="right")
|
||||
assert "Swipe completed successfully." in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_swipe_invalid_action(mock_ctx):
|
||||
"""Verify error handling on invalid action."""
|
||||
with patch("artemis.tools.mobile.exec_tools.UnifiedMobileController"):
|
||||
result = await swipe.execute(ctx=mock_ctx, action="diagonal")
|
||||
assert "Error during swipe: Invalid" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_swipe_tool_langchain_ainvoke(mock_ctx):
|
||||
"""Verify get_swipe_tool exports a LangChain tool that works with ainvoke."""
|
||||
sw_tool = get_swipe_tool(mock_ctx)
|
||||
assert sw_tool.name == "swipe"
|
||||
|
||||
with patch("artemis.tools.mobile.exec_tools.UnifiedMobileController") as mock_controller_cls:
|
||||
mock_controller_inst = MagicMock()
|
||||
mock_controller_inst.swipe_coords = AsyncMock(return_value=None)
|
||||
mock_controller_cls.return_value = mock_controller_inst
|
||||
|
||||
result = await sw_tool.ainvoke({"action": "up"})
|
||||
assert "Swipe completed successfully." in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_swipe_with_direction_and_start_end(mock_driver):
|
||||
"""Verify swipe works with explicit direction and start/end parameters."""
|
||||
# Direction parameter
|
||||
result_dir = await swipe.execute(driver=mock_driver, direction="left", duration=500)
|
||||
mock_driver.swipe_direction.assert_called_with("left", duration_ms=500)
|
||||
assert "Swipe completed successfully." in result_dir
|
||||
|
||||
# Start and End parameters
|
||||
result_drag = await swipe.execute(
|
||||
driver=mock_driver, start=[100, 200], end=[300, 400], duration=1500
|
||||
)
|
||||
mock_driver.swipe.assert_called_with(108, 480, 324, 960, duration_ms=1500)
|
||||
assert "Swipe completed successfully." in result_drag
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_swipe_with_coordinates_alias(mock_driver):
|
||||
"""Verify swipe works with coordinates alias parameter."""
|
||||
result = await swipe.execute(driver=mock_driver, coordinates=[200, 300, 400, 500], duration=800)
|
||||
mock_driver.swipe.assert_called_with(216, 720, 432, 1200, duration_ms=800)
|
||||
assert "Swipe completed successfully." in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_swipe_with_string_coordinates(mock_driver):
|
||||
"""Verify swipe works with string-formatted coordinates."""
|
||||
# List format in string
|
||||
result1 = await swipe.execute(driver=mock_driver, action="[920, 290, 920, 180]", duration=1000)
|
||||
mock_driver.swipe.assert_called_with(993, 696, 993, 432, duration_ms=1000)
|
||||
assert "Swipe completed successfully." in result1
|
||||
|
||||
# Space-separated format in string
|
||||
result2 = await swipe.execute(driver=mock_driver, action="920 290 920 180", duration=1000)
|
||||
assert "Swipe completed successfully." in result2
|
||||
|
||||
|
||||
def test_parse_swipe_parameters_direct():
|
||||
"""Verify parse_swipe_parameters with various parameter formats."""
|
||||
from artemis.utils.coordinates import parse_swipe_parameters
|
||||
|
||||
# List
|
||||
k, t, d = parse_swipe_parameters([100, 200, 300, 400])
|
||||
assert k == "coords"
|
||||
assert t == [100, 200, 300, 400]
|
||||
|
||||
# String list
|
||||
k, t, d = parse_swipe_parameters("[920, 290, 920, 180]")
|
||||
assert k == "coords"
|
||||
assert t == [920, 290, 920, 180]
|
||||
|
||||
# String space separated
|
||||
k, t, d = parse_swipe_parameters("920 290 920 180")
|
||||
assert k == "coords"
|
||||
assert t == [920, 290, 920, 180]
|
||||
|
||||
# Dict with action string
|
||||
k, t, d = parse_swipe_parameters({"action": "[920, 290, 920, 180]", "duration": 1000})
|
||||
assert k == "coords"
|
||||
assert t == [920, 290, 920, 180]
|
||||
assert d == 1000
|
||||
|
||||
# Dict with start and end
|
||||
k, t, d = parse_swipe_parameters({"start": [100, 200], "end": [300, 400]})
|
||||
assert k == "coords"
|
||||
assert t == [100, 200, 300, 400]
|
||||
|
||||
# Direction string
|
||||
k, t, d = parse_swipe_parameters({"action": "up"})
|
||||
assert k == "direction"
|
||||
assert t == "up"
|
||||
|
||||
|
||||
def test_compute_smart_swipe_coordinates_fallback():
|
||||
"""Verify fallback smart swipe coordinates on full screen."""
|
||||
from artemis.utils.coordinates import compute_smart_swipe_coordinates
|
||||
|
||||
sx, sy, ex, ey, dur = compute_smart_swipe_coordinates("up", width=1080, height=2400)
|
||||
assert sx == 648
|
||||
assert ex == 648
|
||||
assert sy == 1680
|
||||
assert ey == 720
|
||||
assert 700 <= dur <= 900
|
||||
|
||||
|
||||
def test_compute_smart_swipe_coordinates_with_target_element():
|
||||
"""Verify container-aware coordinates when target element index is specified."""
|
||||
from artemis.utils.coordinates import compute_smart_swipe_coordinates
|
||||
|
||||
indexed_elements = [
|
||||
{"bounds": "[0, 200][1080, 1200]"}, # Element 1
|
||||
{"bounds": "[100, 500][900, 1500]"}, # Element 2 (width=800, height=1000)
|
||||
]
|
||||
# Swipe up within element 2
|
||||
sx, sy, ex, ey, dur = compute_smart_swipe_coordinates(
|
||||
direction="up",
|
||||
target=2,
|
||||
indexed_elements=indexed_elements,
|
||||
width=1080,
|
||||
height=2400,
|
||||
)
|
||||
# c_left=100, c_top=500, c_width=800, c_height=1000
|
||||
# sx = ex = 100 + 800 * 0.6 = 580
|
||||
# sy = 500 + 1000 * 0.7 = 1200
|
||||
# ey = 500 + 1000 * 0.3 = 800
|
||||
assert sx == 580
|
||||
assert ex == 580
|
||||
assert sy == 1200
|
||||
assert ey == 800
|
||||
|
||||
|
||||
def test_compute_smart_swipe_coordinates_with_scrollable_hierarchy():
|
||||
"""Verify auto-detection of scrollable container in UI hierarchy."""
|
||||
from artemis.utils.coordinates import compute_smart_swipe_coordinates
|
||||
|
||||
ui_hierarchy = [
|
||||
{
|
||||
"class": "android.widget.FrameLayout",
|
||||
"bounds": "[0, 0][1080, 2400]",
|
||||
"children": [
|
||||
{
|
||||
"class": "androidx.recyclerview.widget.RecyclerView",
|
||||
"scrollable": True,
|
||||
"bounds": "[50, 300][1030, 2100]", # width=980, height=1800
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
sx, sy, ex, ey, dur = compute_smart_swipe_coordinates(
|
||||
direction="down",
|
||||
ui_hierarchy=ui_hierarchy,
|
||||
width=1080,
|
||||
height=2400,
|
||||
)
|
||||
# c_left=50, c_top=300, c_width=980, c_height=1800
|
||||
# sx = ex = 50 + 980 * 0.6 = 638
|
||||
# sy = 300 + 1800 * 0.3 = 840
|
||||
# ey = 300 + 1800 * 0.7 = 1560
|
||||
assert sx == 638
|
||||
assert ex == 638
|
||||
assert sy == 840
|
||||
assert ey == 1560
|
||||
Reference in new issue
Block a user