Files
Artemis-Eapil/tests/unit/agents/test_planner_node.py
T

160 lines
5.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
from artemis.agents.planner.planner import PlannerNode
from artemis.context import DevicePlatform, ArtemisContext
import pytest
class DummyState:
def __init__(
self,
initial_goal,
needs_replan=False,
operator_raw_data=None,
operator_replan_reason=None,
):
self.initial_goal = initial_goal
self.needs_replan = needs_replan
self.operator_raw_data = operator_raw_data
self.operator_replan_reason = operator_replan_reason
import base64
from io import BytesIO
from PIL import Image
def _valid_screen_base64():
buffer = BytesIO()
Image.new("RGB", (64, 64), "white").save(buffer, format="PNG")
return base64.b64encode(buffer.getvalue()).decode("ascii")
@pytest.fixture
def mock_context():
ctx = Mock(spec=ArtemisContext)
ctx.device = Mock()
ctx.device.mobile_platform = DevicePlatform.ANDROID
ctx.adb_client = Mock()
ctx.ui_adb_client = Mock()
mock_screen_data = Mock()
mock_screen_data.base64 = _valid_screen_base64()
mock_screen_data.elements = []
mock_screen_data.width = 1080
mock_screen_data.height = 2400
ctx.ui_adb_client.get_screen_data.return_value = mock_screen_data
ctx.data_engine = None
return ctx
@pytest.mark.asyncio
async def test_planner_initial_plan(mock_context):
state = DummyState(
initial_goal="Test Goal",
operator_raw_data={"screenshot_b64": "dummy_base64_string"},
)
node = PlannerNode(mock_context)
mock_llm = Mock()
mock_response = Mock()
mock_response.content = "- [ ] Subgoal 1"
mock_response.tool_calls = []
async def mock_astream(*args, **kwargs):
yield mock_response
mock_llm.astream.side_effect = mock_astream
mock_llm.bind_tools = Mock(return_value=mock_llm)
with patch("artemis.agents.planner.planner.get_llm", return_value=mock_llm):
await node(state)
assert mock_llm.astream.called
args, kwargs = mock_llm.astream.call_args
messages = args[0]
system_msg = messages[0].content
assert "## Role" in system_msg
human_msg_content = messages[1].content
assert isinstance(human_msg_content, list)
assert human_msg_content[0]["type"] == "text"
assert "Goal: Test Goal" in human_msg_content[0]["text"]
@pytest.mark.asyncio
@pytest.mark.parametrize("target", ["mixed", "computer"])
async def test_planner_knows_computer_plugins_with_mobile_primary_screen(mock_context, target):
state = DummyState(initial_goal="按人工示范浏览首页")
mock_context.execution_target = target
mock_context.plugins = SimpleNamespace(prompt=lambda: "电脑插件:cua-driver,可按需加载")
controller = Mock()
controller.take_screenshot = AsyncMock(return_value=_valid_screen_base64())
controller.get_screen_data = AsyncMock(
return_value=SimpleNamespace(observation_text="电脑窗口状态")
)
llm = Mock()
async def stream(*_args, **_kwargs):
yield SimpleNamespace(content="- [ ] 浏览首页", tool_calls=[])
llm.astream.side_effect = stream
llm.bind_tools.return_value = llm
with (
patch("artemis.agents.planner.planner.get_llm", return_value=llm),
patch("artemis.agents.planner.planner.UnifiedMobileController", return_value=controller),
):
await PlannerNode(mock_context)(state)
content = llm.astream.call_args.args[0][1].content
text = "\n".join(part["text"] for part in content if part["type"] == "text")
assert text.count("电脑插件:cua-driver") == 1
assert "规划器只使用本阶段绑定的笔记工具" in text
assert "不表示执行器缺少这些能力" in text
assert "实际加载与操作回执" in text
bound_names = {tool.name for tool in llm.bind_tools.call_args.args[0]}
assert "plugin_load" not in bound_names
assert f"当前主设备截图({'电脑' if target == 'computer' else '安卓'})" in text
assert "不决定其他步骤的执行平台" in text
def test_validate_plan_format_single_sourced_status_alphabet():
"""Regression: the Planner's format gate is single-sourced from
plan_grammar — a legal in-progress '[/]' line must never be rejected."""
from artemis.agents.planner.planner import validate_plan_format
valid, err = validate_plan_format(
"- [x] Done milestone\n- [/] In-progress milestone\n - [ ] Sub\n- [!] Blocked\n"
)
assert valid is True and err == ""
# Every grammar status char is accepted; unknown ones are still rejected.
from artemis.utils.plan_grammar import STATUS_CHARS
for c in STATUS_CHARS:
ok, _ = validate_plan_format(f"- [{c}] Milestone\n")
assert ok is True
bad, message = validate_plan_format("- [z] Bogus status\n")
assert bad is False
assert "Invalid task status" in message
empty, message = validate_plan_format("# just prose\n")
assert empty is False
assert "at least one subgoal" in message