Files
Artemis-Eapil/tests/unit/test_exploration_observation.py
T

113 lines
4.4 KiB
Python

"""Unavailable devices park exploration; unexecuted guidance survives the pause."""
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, Mock
import pytest
from artemis.exploration import runtime
from artemis.graph.perception import perception_node
from artemis.graph.state import State
from artemis.runtime import task_control
class FakeLock:
device_id = "test-device"
description = "observation"
max_concurrency = None
concurrency_mode = "per_device"
ingress = "test"
lock_scope = None
def __init__(self, *args, **kwargs):
self.released = False
def release(self):
self.released = True
def acquire(self, **kwargs):
pass
@pytest.mark.asyncio
async def test_timeout_parks_without_consuming_guidance_then_fresh_observation_resumes(
tmp_path, monkeypatch
):
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
monkeypatch.setattr(task_control, "DeviceExecutionLock", FakeLock)
repository = Mock()
monkeypatch.setattr(runtime, "explorations", repository)
control = runtime.ExplorationControl("run", FakeLock())
ctx = MagicMock()
ctx.exploration_id = "conversation"
ctx.exploration_run_id = "run"
ctx.task_control = control
ctx.on_task_pause = AsyncMock()
ctx.on_task_resume = AsyncMock()
ctx.data_engine.base_dir = tmp_path / "run"
ctx.data_engine.current_step_id = "step"
ctx.data_engine.get_image_path.return_value = tmp_path / "frame.jpg"
controller = SimpleNamespace(
get_screen_data=AsyncMock(
side_effect=[
TimeoutError("设备屏幕观测超时(60 秒)"),
SimpleNamespace(
base64="ZHVtbXk=", elements=[], width=100, height=200, platform="android"
),
SimpleNamespace(
base64="ZnJlc2g=", elements=[], width=100, height=200, platform="android"
),
]
)
)
monkeypatch.setattr("artemis.graph.perception.UnifiedMobileController", lambda _: controller)
monkeypatch.setattr("artemis.graph.perception.is_ocr_configured", lambda: False)
task_control.add_instruction("run", "打开浏览器后等我指导", instruction_id="guidance")
running = asyncio.create_task(perception_node(State(initial_goal="explore"), ctx))
async def wait_for_pause():
while task_control.read_state("run").get("status") != "task_paused":
await asyncio.sleep(0.01)
try:
await asyncio.wait_for(wait_for_pause(), 2)
assert control.pause_reason == "device_observation"
assert control.device_lock.released
assert task_control.list_instructions("run")[0]["status"] == "pending"
await asyncio.sleep(0.05)
assert controller.get_screen_data.await_count == 1
assert not running.done()
repository.add_message.assert_called_once()
task_control.request_resume("run")
result = await asyncio.wait_for(running, 2)
assert result["injected_instruction"] == "打开浏览器后等我指导"
assert task_control.list_instructions("run")[0]["status"] == "consumed"
assert controller.get_screen_data.await_count == 3
assert result["operator_raw_data"]["screenshot_b64"] == "ZnJlc2g="
ctx.on_task_resume.assert_awaited_once()
assert task_control.read_state("run")["status"] == "running"
repository.finish_run.assert_not_called()
finally:
running.cancel()
await asyncio.gather(running, return_exceptions=True)
@pytest.mark.asyncio
@pytest.mark.parametrize("error", [TimeoutError("capture"), ValueError("bad implementation")])
async def test_ordinary_pro_errors_are_not_parked(error):
ctx = SimpleNamespace(task_control=None)
controller = SimpleNamespace(get_screen_data=AsyncMock(side_effect=error))
with pytest.raises(type(error)):
await runtime.observe_or_pause(ctx, controller)
controller.get_screen_data.assert_awaited_once()
@pytest.mark.asyncio
async def test_exploration_programming_error_is_not_hidden(tmp_path, monkeypatch):
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
ctx = SimpleNamespace(task_control=runtime.ExplorationControl("run", FakeLock()))
controller = SimpleNamespace(get_screen_data=AsyncMock(side_effect=ValueError("bad code")))
with pytest.raises(ValueError, match="bad code"):
await runtime.observe_or_pause(ctx, controller)