fix: guard transient controls and verify timeline endpoints
This commit is contained in:
1 parent
479a185192
commit
00e62a663a
29 files changed
+1150
-90
No files matched your search
@@ -134,7 +134,9 @@ async def test_fallback_observation_renders_the_indexed_ui_list(mock_context, tm
|
||||
|
||||
assert post == _POST_IMG
|
||||
assert isinstance(exec_result.ui_elements_text, str)
|
||||
assert exec_result.ui_elements_text == "[1] Text: 'Settings' | Bounds: [37,50][962,112]"
|
||||
assert exec_result.ui_elements_text == (
|
||||
"[1] Text: 'Settings' | Bounds: [37,50][962,112] | resource_id: 'android:id/title'"
|
||||
)
|
||||
assert "{'text'" not in exec_result.ui_elements_text
|
||||
# State refreshed exactly as the executor's success path does.
|
||||
assert state.indexed_elements[0]["index"] == 1
|
||||
|
||||
@@ -23,6 +23,7 @@ feeds the safety-net XML validation.
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
from langchain_core.messages import AIMessage
|
||||
import pytest
|
||||
@@ -309,6 +310,48 @@ async def test_validator_burst_success_executes_every_member(
|
||||
assert len(mock_mcp.calls_for("click")) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("post_image", ["screenshot", "new-sequence-image"])
|
||||
async def test_sequence_captures_once_after_whole_burst_and_retains_equal_hash(
|
||||
mock_mcp, mock_context, temp_screenshot, post_image
|
||||
):
|
||||
events = []
|
||||
|
||||
def action(name, args):
|
||||
events.append(name)
|
||||
return True, ""
|
||||
|
||||
def screenshot():
|
||||
events.append("capture")
|
||||
return "ZHVtbXlfZGF0YQ=="
|
||||
|
||||
mock_mcp.action_handler = action
|
||||
mock_mcp.screenshot = screenshot
|
||||
mock_context.data_engine.get_or_create_image.return_value = post_image
|
||||
mock_context.data_engine.get_image_path.return_value = temp_screenshot
|
||||
state = DummyState(
|
||||
structured_decisions=json.dumps(
|
||||
[
|
||||
{
|
||||
"action": "action_sequence",
|
||||
"args": {"steps": [{"action": "wait", "time_in_ms": 1}]},
|
||||
},
|
||||
{"action": "tap", "coordinates": [105, 205]},
|
||||
]
|
||||
),
|
||||
latest_screenshot=temp_screenshot,
|
||||
current_step_id=str(uuid4()),
|
||||
)
|
||||
|
||||
result = await ValidatorNode(mock_context)(state)
|
||||
|
||||
assert result["last_execution_result"]["status"] == "dispatched"
|
||||
assert events == ["action_sequence", "click", "capture"]
|
||||
assert mock_context.data_engine.update_step_execution_result.call_args.kwargs == {
|
||||
"post_image_name": post_image
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validator_success_closes_open_incident(mock_mcp, mock_context, temp_screenshot):
|
||||
"""A successful turn closes whatever incident was still open."""
|
||||
|
||||
@@ -111,7 +111,12 @@ def test_click_index_resolves_to_the_elements_center_and_records_observed_fields
|
||||
)
|
||||
assert wire_name == "click"
|
||||
# 540/1080 -> 500, 1248/2400 -> 520 (normalized 0-1000)
|
||||
assert wire_args == {"target": [500, 520], "times": 1, "delay_ms": 100}
|
||||
assert wire_args == {
|
||||
"target": [500, 520],
|
||||
"times": 1,
|
||||
"delay_ms": 100,
|
||||
"expected_resource_id": "android:id/title",
|
||||
}
|
||||
assert recorded == {
|
||||
"target_text": "Wi-Fi",
|
||||
"target_bounds": [37, 480, 963, 560],
|
||||
|
||||
@@ -57,7 +57,18 @@ SCOPED_PROMPT_FILES = [
|
||||
# decision.
|
||||
#: Backticked identifiers in prompts that are parameters or note keys, not tools.
|
||||
NON_TOOL_IDENTIFIERS = frozenset(
|
||||
{"analysis", "task_plan", "tool_enum", "target_description", "report_note_key"}
|
||||
{
|
||||
"analysis",
|
||||
"task_plan",
|
||||
"tool_enum",
|
||||
"target_description",
|
||||
"report_note_key",
|
||||
"unless_visible",
|
||||
"require_visible",
|
||||
"sequence_results",
|
||||
"skipped",
|
||||
"not_started",
|
||||
}
|
||||
)
|
||||
|
||||
_TOOL_REF = re.compile(r"(?:`([a-z][a-z0-9_]{2,})`|\b([a-z][a-z0-9_]{2,})\()")
|
||||
|
||||
@@ -90,6 +90,20 @@ async def test_old_bitmap_with_new_hierarchy_is_never_marked_settled(tmp_path):
|
||||
assert session.observe.await_count == 4
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sequence_preserves_immediate_popup_evidence_without_claiming_stability(tmp_path):
|
||||
popup = _observation(_screen(tmp_path, "popup.jpg"), "缩放面板")
|
||||
session = Mock()
|
||||
session.observe = AsyncMock(return_value=popup)
|
||||
executor = McpActionExecutor(Mock(), actuator=Mock())
|
||||
|
||||
observed, settled = await executor._observe_after_action(session, "action_sequence", None)
|
||||
|
||||
assert observed is popup
|
||||
assert settled is None
|
||||
session.observe.assert_awaited_once_with(settle_ms=0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_only_action_keeps_single_observation(tmp_path):
|
||||
home = _screen(tmp_path, "home.jpg")
|
||||
|
||||
@@ -101,6 +101,335 @@ def steps():
|
||||
]
|
||||
|
||||
|
||||
def test_both_observation_lists_expose_real_resource_ids_without_changing_indices():
|
||||
from artemis.utils.visualization import (
|
||||
format_minimal_list_with_elements,
|
||||
format_minimal_list_with_points,
|
||||
)
|
||||
|
||||
nodes = [
|
||||
{"text": "plus", "resource-id": "test.app:id/plus", "bounds": "[10,20][30,40]"},
|
||||
{"text": "minus", "resource_id": "test.app:id/minus", "bounds": "[10,60][30,80]"},
|
||||
]
|
||||
for formatter in (format_minimal_list_with_points, format_minimal_list_with_elements):
|
||||
text, _, labels = formatter(nodes, 100, 100)
|
||||
assert (
|
||||
"[1] Text: 'plus' | Bounds: [100,200][300,400] | resource_id: 'test.app:id/plus'"
|
||||
in text
|
||||
)
|
||||
assert "[2] Text: 'minus'" in text and "resource_id: 'test.app:id/minus'" in text
|
||||
assert labels == ["1", "2"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_resource_click_uses_current_bounds_and_reuses_guard_observation():
|
||||
actuator = MockActuator(width=720, height=1600)
|
||||
panel, opener = "test.app:id/panel", "test.app:id/opener"
|
||||
actuator.controller.get_ui_elements = AsyncMock(
|
||||
return_value=[
|
||||
{"resource_id": opener, "bounds": [100, 1400, 200, 1500]},
|
||||
]
|
||||
)
|
||||
actuator.controller.tap_at = AsyncMock(return_value=SimpleNamespace(error=None))
|
||||
result = await actuator.action_sequence(
|
||||
[
|
||||
{
|
||||
"action": "click",
|
||||
"target": opener,
|
||||
"target_description": "open",
|
||||
"unless_visible": panel,
|
||||
}
|
||||
]
|
||||
)
|
||||
assert result.ok
|
||||
actuator.controller.tap_at.assert_awaited_once_with(150, 1450)
|
||||
actuator.controller.get_ui_elements.assert_awaited_once()
|
||||
receipt = result.metadata["sequence_results"][0]
|
||||
assert receipt["coordinates"] == [150, 1450]
|
||||
assert receipt["target_resource_id"] == opener
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("matches", [0, 2])
|
||||
async def test_missing_or_ambiguous_resource_click_never_guesses_coordinates(matches):
|
||||
actuator = MockActuator(width=720, height=1600)
|
||||
target = "test.app:id/control"
|
||||
actuator.controller.get_ui_elements = AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
"resource_id": target if i < matches else "test.app:id/other",
|
||||
"bounds": [10, 20, 30, 40],
|
||||
}
|
||||
for i in range(2)
|
||||
]
|
||||
)
|
||||
actuator.controller.tap_at = AsyncMock()
|
||||
result = await actuator.action_sequence(
|
||||
[{"action": "click", "target": target, "target_description": "control"}]
|
||||
)
|
||||
assert not result.ok and result.code.value == "TARGET_NOT_FOUND"
|
||||
assert result.metadata["execution"] == "not_started"
|
||||
actuator.controller.tap_at.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"resource_id,bounds,success",
|
||||
[
|
||||
("test.app:id/control", [100, 1400, 200, 1500], True),
|
||||
("test.app:id/other", [100, 1400, 200, 1500], False),
|
||||
("test.app:id/control", [300, 1400, 400, 1500], False),
|
||||
],
|
||||
)
|
||||
async def test_indexed_click_checks_live_identity_at_original_position(
|
||||
resource_id, bounds, success
|
||||
):
|
||||
actuator = MockActuator(width=720, height=1600)
|
||||
actuator.controller.get_ui_elements = AsyncMock(
|
||||
return_value=[{"resource_id": resource_id, "bounds": bounds}]
|
||||
)
|
||||
actuator.controller.tap_at = AsyncMock(return_value=SimpleNamespace(error=None))
|
||||
result = await actuator.click(217, 906, expected_resource_id="test.app:id/control")
|
||||
assert result.ok is success
|
||||
assert actuator.controller.tap_at.await_count == int(success)
|
||||
if success:
|
||||
from artemis.agents.flash.runner import FlashRunner
|
||||
|
||||
assert result.metadata["pixel_coordinates"] == [156, 1449]
|
||||
assert "coordinates" not in result.metadata
|
||||
runner = object.__new__(FlashRunner)
|
||||
record = runner._build_action_record(
|
||||
"click", {"target": [217, 906]}, SimpleNamespace(metadata=result.metadata)
|
||||
)
|
||||
assert record["coordinates"] == [217, 906]
|
||||
assert record["coordinate_space"] == "normalized"
|
||||
assert record["completed_taps"] == 1
|
||||
assert record["pixel_coordinates"] == [156, 1449]
|
||||
else:
|
||||
assert result.metadata["execution"] == "not_started"
|
||||
assert result.metadata["side_effect"] == "none"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_coordinate_only_click_keeps_existing_direct_dispatch():
|
||||
actuator = MockActuator(width=720, height=1600)
|
||||
actuator.controller.get_ui_elements = AsyncMock()
|
||||
result = await actuator.click(217, 906)
|
||||
assert result.ok
|
||||
actuator.controller.get_ui_elements.assert_not_awaited()
|
||||
|
||||
|
||||
def test_flash_and_pro_forward_observed_resource_identity_without_inventing_it():
|
||||
from artemis.mcp.action_executor import McpActionExecutor
|
||||
from artemis.mcp.action_names import to_canonical_call
|
||||
|
||||
executor = object.__new__(McpActionExecutor)
|
||||
executor._resolve_target = MagicMock(
|
||||
return_value=([217, 906], {"target_resource_id": "test.app:id/control"})
|
||||
)
|
||||
name, args, _, _ = executor._translate("click", {"target": 1}, MagicMock())
|
||||
assert name == "click" and args["expected_resource_id"] == "test.app:id/control"
|
||||
_, args = to_canonical_call(
|
||||
{
|
||||
"action": "tap",
|
||||
"normalized_coordinates": [217, 906],
|
||||
"resource_id": "test.app:id/control",
|
||||
}
|
||||
)
|
||||
assert args["expected_resource_id"] == "test.app:id/control"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repeated_resource_taps_are_one_bounded_sequence_member():
|
||||
actuator = MockActuator(width=720, height=1600)
|
||||
actuator.controller.get_ui_elements = AsyncMock(
|
||||
return_value=[{"resource_id": "test.app:id/plus", "bounds": [10, 20, 30, 40]}]
|
||||
)
|
||||
actuator.controller.tap_at = AsyncMock(return_value=SimpleNamespace(error=None))
|
||||
result = await actuator.action_sequence(
|
||||
[
|
||||
{
|
||||
"action": "click",
|
||||
"target": "test.app:id/plus",
|
||||
"target_description": "plus",
|
||||
"times": 10,
|
||||
"delay_ms": 50,
|
||||
}
|
||||
]
|
||||
)
|
||||
assert result.ok and result.metadata["completed_steps"] == 1
|
||||
assert actuator.controller.tap_at.await_count == 10
|
||||
actuator.controller.tap_at.assert_awaited_with(20, 30)
|
||||
assert actuator.controller.get_ui_elements.await_count == 10
|
||||
receipt = result.metadata["sequence_results"][0]
|
||||
assert receipt["times"] == receipt["completed_taps"] == 10
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("sequence", [True, False])
|
||||
async def test_repeated_native_taps_stop_when_target_disappears(sequence):
|
||||
actuator = MockActuator(width=720, height=1600)
|
||||
actuator.controller.get_ui_elements = AsyncMock(
|
||||
side_effect=[
|
||||
[{"resource_id": "test.app:id/plus", "bounds": [10, 20, 30, 40]}],
|
||||
[{"resource_id": "test.app:id/background", "bounds": [0, 0, 720, 1600]}],
|
||||
]
|
||||
)
|
||||
actuator.controller.tap_at = AsyncMock(return_value=SimpleNamespace(error=None))
|
||||
if sequence:
|
||||
result = await actuator.action_sequence(
|
||||
[
|
||||
{
|
||||
"action": "click",
|
||||
"target": "test.app:id/plus",
|
||||
"target_description": "plus",
|
||||
"times": 3,
|
||||
"delay_ms": 0,
|
||||
},
|
||||
{"action": "click", "target": [100, 100], "target_description": "next"},
|
||||
]
|
||||
)
|
||||
receipt = result.metadata["sequence_results"][0]
|
||||
assert result.metadata["sequence_results"][1]["execution"] == "not_started"
|
||||
else:
|
||||
result = await actuator.click(
|
||||
28, 19, times=3, delay_ms=0, expected_resource_id="test.app:id/plus"
|
||||
)
|
||||
receipt = result.metadata
|
||||
assert not result.ok and result.code.value == "TARGET_NOT_FOUND"
|
||||
assert receipt["completed_taps"] == 1
|
||||
assert receipt["execution"] == "unknown"
|
||||
actuator.controller.tap_at.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("times", [0, 11, True])
|
||||
async def test_invalid_repeat_count_rejects_entire_sequence_before_input(times):
|
||||
actuator = MockActuator(width=720, height=1600)
|
||||
actuator.controller.tap_at = AsyncMock()
|
||||
invalid = steps()
|
||||
invalid[-1] = {
|
||||
"action": "click",
|
||||
"target": [200, 900],
|
||||
"target_description": "plus",
|
||||
"times": times,
|
||||
}
|
||||
result = await actuator.action_sequence(invalid)
|
||||
assert not result.ok and result.code.value == "INVALID_ARGS"
|
||||
actuator.controller.tap_at.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("limit", [2, 4, 8])
|
||||
def test_runtime_sequence_contract_matches_both_modes_and_counts_waits(limit):
|
||||
from langchain_core.utils.function_calling import convert_to_openai_tool
|
||||
from artemis.mcp.action_specs import operator_shell_tool, tool_declaration
|
||||
from artemis.mcp.gestures import validate_sequence
|
||||
|
||||
pro = operator_shell_tool("action_sequence", sequence_limit=limit)
|
||||
flash = tool_declaration("action_sequence", sequence_limit=limit)
|
||||
assert flash.parameters == convert_to_openai_tool(pro)["function"]["parameters"]
|
||||
assert flash.parameters["properties"]["steps"]["maxItems"] == limit
|
||||
assert f"at most {limit} members" in pro.description
|
||||
with pytest.raises(ValueError, match=f"at most {limit}, including waits"):
|
||||
validate_sequence({"steps": [{"action": "wait", "time_in_ms": 1}] * 13}, limit)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("already_open", [False, True])
|
||||
async def test_guarded_popup_opener_handles_open_and_closed_without_toggling(already_open):
|
||||
actuator = MockActuator(width=720, height=1600)
|
||||
panel = "test.app:id/panel_control"
|
||||
guarded = steps()
|
||||
guarded[0]["unless_visible"] = panel
|
||||
guarded[-1]["require_visible"] = panel
|
||||
actuator.controller.get_ui_elements = AsyncMock(
|
||||
side_effect=[
|
||||
[{"resource_id": panel if already_open else "test.app:id/opener"}],
|
||||
[{"resource_id": panel}],
|
||||
]
|
||||
)
|
||||
actuator.controller.tap_at = AsyncMock(return_value=SimpleNamespace(error=None))
|
||||
actuator.controller.swipe_coords = AsyncMock(return_value=None)
|
||||
result = await actuator.action_sequence(guarded)
|
||||
assert result.ok
|
||||
assert actuator.controller.tap_at.await_count == (0 if already_open else 1)
|
||||
actuator.controller.swipe_coords.assert_awaited_once()
|
||||
assert actuator.controller.get_ui_elements.await_count == 2
|
||||
assert result.metadata["skipped_steps"] == int(already_open)
|
||||
assert result.metadata["sequence_results"][0]["execution"] == (
|
||||
"skipped" if already_open else "completed"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_popup_expiring_before_drag_never_touches_underlying_content():
|
||||
actuator = MockActuator(width=720, height=1600)
|
||||
panel = "test.app:id/panel_control"
|
||||
guarded = steps()
|
||||
guarded[0]["unless_visible"] = panel
|
||||
guarded[-1]["require_visible"] = panel
|
||||
actuator.controller.get_ui_elements = AsyncMock(
|
||||
side_effect=[
|
||||
[{"resource_id": panel}],
|
||||
[{"resource_id": "test.app:id/card"}],
|
||||
]
|
||||
)
|
||||
actuator.controller.tap_at = AsyncMock()
|
||||
actuator.controller.swipe_coords = AsyncMock()
|
||||
result = await actuator.action_sequence(guarded)
|
||||
assert not result.ok and result.code.value == "TARGET_NOT_FOUND"
|
||||
assert result.metadata["execution"] == "not_started"
|
||||
assert result.metadata["side_effect"] == "none"
|
||||
assert [r["execution"] for r in result.metadata["sequence_results"]] == [
|
||||
"skipped",
|
||||
"completed",
|
||||
"not_started",
|
||||
]
|
||||
actuator.controller.tap_at.assert_not_awaited()
|
||||
actuator.controller.swipe_coords.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("hierarchy", [[], RuntimeError("hierarchy unavailable")])
|
||||
async def test_unavailable_hierarchy_does_not_guess_popup_state(hierarchy):
|
||||
actuator = MockActuator(width=720, height=1600)
|
||||
guarded = steps()
|
||||
guarded[0]["unless_visible"] = "test.app:id/panel_control"
|
||||
actuator.controller.get_ui_elements = AsyncMock(
|
||||
side_effect=hierarchy if isinstance(hierarchy, Exception) else lambda: hierarchy
|
||||
)
|
||||
actuator.controller.tap_at = AsyncMock()
|
||||
actuator.controller.swipe_coords = AsyncMock()
|
||||
result = await actuator.action_sequence(guarded)
|
||||
assert not result.ok
|
||||
assert result.metadata["execution"] == "not_started"
|
||||
assert result.metadata["side_effect"] == "none"
|
||||
actuator.controller.tap_at.assert_not_awaited()
|
||||
actuator.controller.swipe_coords.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_standalone_guarded_drag_round_trips_and_refuses_stale_target():
|
||||
actuator = MockActuator(width=720, height=1600)
|
||||
actuator.controller.get_ui_elements = AsyncMock(
|
||||
return_value=[{"resource_id": "test.app:id/card"}]
|
||||
)
|
||||
actuator.controller.swipe_coords = AsyncMock()
|
||||
session = ActionSession(server=build_action_server(actuator=actuator))
|
||||
try:
|
||||
await session.start()
|
||||
request = {k: v for k, v in steps()[-1].items() if k != "action"}
|
||||
result = await session.call(
|
||||
"drag", {**request, "require_visible": "test.app:id/panel_control"}
|
||||
)
|
||||
assert not result.ok and result.code.value == "TARGET_NOT_FOUND"
|
||||
assert result.metadata["execution"] == "not_started"
|
||||
assert not result.metadata["visibility_guard"]["visible"]
|
||||
actuator.controller.swipe_coords.assert_not_awaited()
|
||||
finally:
|
||||
await session.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sequence_has_no_observation_between_inputs_and_returns_member_receipts():
|
||||
actuator = MockActuator(width=720, height=1600)
|
||||
@@ -195,7 +524,7 @@ async def test_over_limit_is_rejected_before_dispatch(monkeypatch):
|
||||
)
|
||||
actuator = MockActuator(width=720, height=1600)
|
||||
result = await actuator.action_sequence(steps())
|
||||
assert not result.ok and "limit of 2" in result.message
|
||||
assert not result.ok and "at most 2, including waits" in result.message
|
||||
assert actuator.action_history == []
|
||||
|
||||
|
||||
|
||||
@@ -284,6 +284,39 @@ async def test_android_driver_parses_xml_hierarchy_fallback():
|
||||
assert any(element.get("text") == "Settings" for element in screen_data.ui_elements)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_android_live_targets_read_fresh_hierarchy_without_screenshot():
|
||||
client = MagicMock()
|
||||
client.get_hierarchy.side_effect = [
|
||||
'<hierarchy><node resource-id="app:id/plus" bounds="[10,20][30,40]"/></hierarchy>',
|
||||
"<hierarchy/>",
|
||||
]
|
||||
driver = AndroidAdbDriver("test-device", MagicMock(), ui_adb_client=client)
|
||||
driver.get_screen_data = AsyncMock(side_effect=AssertionError("must not capture screen"))
|
||||
|
||||
present = await driver.get_ui_elements()
|
||||
absent = await driver.get_ui_elements()
|
||||
|
||||
assert any(e.get("resource-id") == "app:id/plus" for e in present)
|
||||
assert not any(e.get("resource-id") == "app:id/plus" for e in absent)
|
||||
assert client.get_hierarchy.call_count == 2
|
||||
client.get_screen_data.assert_not_called()
|
||||
driver.get_screen_data.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_android_live_hierarchy_errors_do_not_fall_back_to_stale_screen():
|
||||
client = MagicMock()
|
||||
client.get_hierarchy.side_effect = TimeoutError("hierarchy unavailable")
|
||||
driver = AndroidAdbDriver("test-device", MagicMock(), ui_adb_client=client)
|
||||
driver.get_screen_data = AsyncMock()
|
||||
|
||||
with pytest.raises(TimeoutError, match="hierarchy unavailable"):
|
||||
await driver.get_ui_elements()
|
||||
|
||||
driver.get_screen_data.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_element_prefers_fresh_bounds_over_stale_center():
|
||||
"""Layout transitions must not leave dynamic element taps at old coordinates."""
|
||||
|
||||
Reference in new issue
Block a user