"""Regress real actuator dispatch, state verification and value convergence without devices.""" from types import SimpleNamespace from unittest.mock import AsyncMock import pytest from artemis.mcp import value_feedback from artemis.mcp.action_manifest import DEVICE_ACTIONS from artemis.mcp.actuators import MockActuator from artemis.mcp.gestures import DragRequest, plan_drag from artemis.mcp.native_actions import PinchRequest, plan_pinch from artemis.mcp.value_feedback import read_value @pytest.fixture(autouse=True) def deterministic_clock(monkeypatch): now = [0.0] async def sleep(seconds): now[0] += seconds monkeypatch.setattr(value_feedback, "monotonic", lambda: now[0]) monkeypatch.setattr(value_feedback, "sleep", sleep) monkeypatch.setattr( "artemis.config.agent.load_agent_config", lambda: SimpleNamespace( pro=SimpleNamespace(execution=SimpleNamespace(max_burst_actions=10)) ), ) def setup_actuator(*, current="100", after="100", readable=True): actuator = MockActuator(width=720, height=1600, capabilities=DEVICE_ACTIONS) state = {"text": current, "playing": False} events = [] async def native(command, args, **kwargs): events.append(command) if command == "control_action": state["playing"] = not state["playing"] return {"success": True, "execution": "completed", "code": "OK"} is_player = args["selector"]["resource_id"] == "player" return { "success": True, "execution": "observed", "code": "OK", "node": { "token": "a" * 64, "content_signature": "b" * 64, "visible": True, "enabled": True, "password": False, "actions": ["click"], "text": state["text"] if readable else None, "content_desc": ("暂停" if state["playing"] else "播放") if is_player else "readout", }, } async def swipe(*args): events.append("swipe") state.update(text=after, playing=True) return None actuator.controller.native_request = AsyncMock(side_effect=native) actuator.controller.swipe_coords = AsyncMock(side_effect=swipe) actuator.controller.tap_at = AsyncMock(return_value=SimpleNamespace(error=None)) return actuator, state, events def feedback(**overrides): return { "selector": {"resource_id": "readout"}, "value_format": "number", "target_value": 90.0, "tolerance": 1.0, "stable_ms": 100, "timeout_ms": 500, **overrides, } def drag(**overrides): return { "start": [300, 1050], "end": [325, 1050], "coordinate_space": "pixel", "target_description": "observed timeline", **overrides, } @pytest.mark.asyncio async def test_two_ineffective_attempts_block_third_but_allow_meaningful_strategy_change(): actuator, _, _ = setup_actuator() for _ in range(2): result = await actuator.drag(**drag(feedback=feedback())) assert not result.ok and result.metadata["execution"] == "completed" assert result.metadata["value_feedback"]["after_value"] == 100 result = await actuator.drag(**drag(feedback=feedback())) assert not result.ok and "NO_PROGRESS" in result.message assert result.metadata["execution"] == "not_started" assert actuator.controller.swipe_coords.await_count == 2 # Tiny endpoint jitter is the same strategy, not an escape from the guard. await actuator.drag(**drag(end=[328, 1050], feedback=feedback())) assert actuator.controller.swipe_coords.await_count == 2 await actuator.drag(**drag(end=[424, 1050], feedback=feedback())) assert actuator.controller.swipe_coords.await_count == 3 # History belongs to this actuator/task, not a global cache. other, _, _ = setup_actuator() await other.drag(**drag(feedback=feedback())) assert other.controller.swipe_coords.await_count == 1 @pytest.mark.asyncio async def test_changed_actual_value_resets_stalled_history(): actuator, state, _ = setup_actuator() for _ in range(2): await actuator.drag(**drag(feedback=feedback())) state["text"] = "95" await actuator.drag(**drag(feedback=feedback())) assert actuator.controller.swipe_coords.await_count == 3 @pytest.mark.asyncio async def test_one_pixel_change_across_power_of_two_is_still_the_same_strategy(): actuator, _, _ = setup_actuator() for endpoint in (331, 332, 333): await actuator.drag(**drag(end=[endpoint, 1050], feedback=feedback())) assert actuator.controller.swipe_coords.await_count == 2 # A substantially slower gesture is a meaningful new approach. await actuator.drag(**drag(end=[333, 1050], duration_ms=1500, feedback=feedback())) assert actuator.controller.swipe_coords.await_count == 3 @pytest.mark.asyncio async def test_sequence_reads_final_value_only_after_pause_and_skips_redundant_toggle(): actuator, state, events = setup_actuator( current="11-02 周一 17:43:12", after="11-02 周一 17:42:17" ) pause = { "action": "control_action", "request": { "action": "click", "selector": {"resource_id": "player"}, "ensure_state": {"state": "content_desc", "expected_text": "播放"}, }, } result = await actuator.action_sequence( [pause, {"action": "drag", **drag()}, pause], feedback=feedback( value_format="clock", target_value=17.0 * 3600 + 42 * 60 + 17, tolerance=5.0 ), ) assert result.ok and not state["playing"] assert result.metadata["skipped_steps"] == 1 assert result.metadata["value_feedback"]["residual"] == 0 observation = result.metadata["value_feedback"]["after_observation"] assert observation["node"]["text"] == "11-02 周一 17:42:17" assert observation["stable_for_ms"] >= 100 and observation["samples"] >= 2 assert result.metadata["verification"] == "value_matched" assert events.count("control_action") == 1 assert events.index("swipe") < events.index("control_action") assert events[-1] == "inspect_control" @pytest.mark.asyncio async def test_missing_value_refuses_input_and_postwrite_read_failure_preserves_receipt(): actuator, _, _ = setup_actuator(readable=False) result = await actuator.drag(**drag(feedback=feedback())) assert not result.ok and result.metadata["execution"] == "not_started" actuator.controller.swipe_coords.assert_not_awaited() actuator, _, _ = setup_actuator(after="unreadable") result = await actuator.drag(**drag(feedback=feedback())) assert not result.ok and result.metadata["execution"] == "completed" assert result.metadata["coordinates"] == [300, 1050, 325, 1050] assert result.metadata["verification"] == "unavailable" actuator.controller.swipe_coords.assert_awaited_once() @pytest.mark.asyncio async def test_native_feedback_unavailable_has_no_coordinate_fallback(): actuator = MockActuator(capabilities=DEVICE_ACTIONS - {"read_control", "wait_until"}) actuator.controller.swipe_coords = AsyncMock() result = await actuator.drag(**drag(feedback=feedback())) assert not result.ok and result.code == "UNSUPPORTED" actuator.controller.swipe_coords.assert_not_awaited() @pytest.mark.asyncio async def test_whole_sequence_rejects_bad_region_before_any_click(): actuator, _, _ = setup_actuator() result = await actuator.action_sequence( [ {"action": "click", "target": [50, 50], "target_description": "opener"}, {"action": "drag", **drag(region=[400, 970, 650, 1118])}, ] ) assert not result.ok actuator.controller.tap_at.assert_not_awaited() actuator.controller.swipe_coords.assert_not_awaited() @pytest.mark.asyncio async def test_failed_state_verification_keeps_completed_members_and_stops_next_input(): actuator, _, _ = setup_actuator() async def unchanged(command, args, **kwargs): if command == "control_action": return {"success": True, "execution": "completed", "code": "OK"} return { "success": True, "execution": "observed", "code": "OK", "node": { "token": "a" * 64, "visible": True, "enabled": True, "text": "100", "content_desc": "暂停", "actions": ["click"], }, } actuator.controller.native_request = AsyncMock(side_effect=unchanged) result = await actuator.action_sequence( [ {"action": "drag", **drag()}, { "action": "control_action", "request": { "action": "click", "selector": {"resource_id": "player"}, "ensure_state": {"state": "content_desc", "expected_text": "播放"}, "verify_timeout_ms": 1, }, }, {"action": "click", "target": [50, 50], "target_description": "must not execute"}, ], feedback=feedback(), ) assert not result.ok and result.metadata["execution"] == "partial" assert [r["execution"] for r in result.metadata["sequence_results"]] == [ "completed", "completed", "not_started", ] assert result.metadata["sequence_results"][1]["verification"] == "condition_not_met" actuator.controller.tap_at.assert_not_awaited() actuator.controller.swipe_coords.assert_awaited_once() @pytest.mark.asyncio async def test_transient_post_action_read_failure_can_settle_without_replaying_input(): actuator, _, events = setup_actuator(after="90") original = actuator.controller.native_request.side_effect failed = False async def transient(command, args, **kwargs): nonlocal failed if "swipe" in events and not failed: failed = True return {"success": False, "execution": "not_started", "code": "READ_FAILED"} return await original(command, args, **kwargs) actuator.controller.native_request = AsyncMock(side_effect=transient) result = await actuator.drag(**drag(feedback=feedback())) assert result.ok and result.metadata["value_feedback"]["after_value"] == 90 actuator.controller.swipe_coords.assert_awaited_once() @pytest.mark.asyncio async def test_feedback_timeout_never_repeats_a_completed_input(monkeypatch): actuator, state, _ = setup_actuator() original = value_feedback.sleep async def moving(seconds): state["text"] = str(float(state["text"]) + 1) await original(seconds) monkeypatch.setattr(value_feedback, "sleep", moving) result = await actuator.drag(**drag(feedback=feedback())) assert not result.ok and result.code == "TIMEOUT" assert result.metadata["execution"] == "completed" assert result.metadata["verification"] == "unavailable" actuator.controller.swipe_coords.assert_awaited_once() @pytest.mark.parametrize( "text", ["17:42:17 / 17:43:12", "25:42:17", "117:42:17", "17:42:170", "17:42"] ) def test_clock_readout_rejects_ambiguous_or_invalid_times(text): with pytest.raises(ValueError): read_value({"visible": True, "text": text}, "clock") @pytest.mark.parametrize( "value_format,node", [ ("number", {"text": "0"}), ("clock", {"text": "00:00:00"}), ("range", {"range": {"min": 0, "current": 0, "max": 10}}), ], ) def test_zero_is_an_actual_value(value_format, node): assert read_value({"visible": True, **node}, value_format) == 0 def test_precision_guard_uses_observed_minimum_and_reports_geometric_budget(): calibration = { "axis": "x", "mode": "thumb", "anchors": [{"position": 100.0, "value": 0.0}, {"position": 200.0, "value": 1000.0}], "target_value": 125.0, "tolerance": 1.0, } request = { "start": [100, 1050], "coordinate_space": "pixel", "target_description": "observed scale", "calibration": calibration, } with pytest.raises(ValueError, match="rounding"): plan_drag(DragRequest.model_validate(request), 720, 1600) calibration.update(tolerance=5.0, min_movement_px=20.0) with pytest.raises(ValueError, match="minimum effective"): plan_drag(DragRequest.model_validate(request), 720, 1600) calibration.update(min_movement_px=10.0) plan = plan_drag(DragRequest.model_validate(request), 720, 1600) assert plan["calibration"]["pixel_error_budget"] == 0.5 assert plan["calibration"]["rounding_error_value"] == 5 def test_both_pinch_fingers_must_stay_in_observed_surface(): request = PinchRequest( center=[360, 656], start_span=40, end_span=80, coordinate_space="pixel", target_description="observed timeline", region=[200, 970, 650, 1118], ) with pytest.raises(ValueError, match="region"): plan_pinch(request, 720, 1600)