"""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)