- core/navigation/ : visual_verifier (presence=OCR, role=VLM ancre sur tokens), grounding (OCR-anchor first, VLM fallback, cache coords valide par la vue), visual_login (verify_before/after, DETTE-023), action_resolver (pont runtime) - api_stream/replay_engine : dispatch action navigate server-side, never-fail -> needs_review, import depuis core.navigation (boot 5005 garanti) - 131 tests verts (wiring boot, e2e handler, unit modules) Chantier Qwen 01-02/07/2026, revue croisee Claude (plan deploy v2). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
152 lines
5.9 KiB
Python
152 lines
5.9 KiB
Python
"""End-to-end mocked test for navigate action handler — 3 edge-case scenarios.
|
|
|
|
Tests the _handle_navigate_action handler with mocked OCR/VLM, verifying:
|
|
- Nominal: all resolved, coords populated in variables
|
|
- OCR miss + VLM fail: no phantom coords, all_resolved=False
|
|
- No screenshot: error="no_screenshot", False return
|
|
|
|
NOTE: The handler uses lazy imports inside its body. Mock targets must be
|
|
at the source module (core.navigation.action_resolver.navigate_login) rather
|
|
than the package-level re-export (core.navigation.navigate_login).
|
|
"""
|
|
|
|
import pytest
|
|
from unittest.mock import patch, MagicMock
|
|
from core.navigation.action_resolver import NavigateCoords, NavigateResult
|
|
from core.navigation import _handle_navigate_action
|
|
|
|
|
|
def _patch_all_deps(navigate_login_result=None, navigate_login_side_effect=None):
|
|
"""Return stacked patches for handler's lazy imports + navigate_login."""
|
|
nl_mock = MagicMock(return_value=navigate_login_result) if navigate_login_result else None
|
|
if navigate_login_side_effect:
|
|
nl_mock = MagicMock(side_effect=navigate_login_side_effect)
|
|
|
|
return (
|
|
patch("core.llm.extract_grid_from_image", return_value=[]),
|
|
patch("core.extraction.vlm_client.make_vllm_client", return_value=MagicMock()),
|
|
patch("core.navigation.action_resolver.make_ocr_detailed_from_grid",
|
|
return_value=MagicMock(return_value=[])),
|
|
patch("core.navigation.action_resolver.navigate_login", nl_mock),
|
|
)
|
|
|
|
|
|
class TestNominalCase:
|
|
"""All fields grounded → coords populated, all_resolved=True."""
|
|
|
|
def test_nominal_coords_populated(self):
|
|
mock_result = NavigateResult(
|
|
login_coords=NavigateCoords(x_pct=0.15, y_pct=0.07, method="ocr_anchor"),
|
|
password_coords=NavigateCoords(x_pct=0.15, y_pct=0.25, method="ocr_anchor"),
|
|
submit_coords=NavigateCoords(x_pct=0.50, y_pct=0.35, method="ocr_anchor"),
|
|
all_resolved=True,
|
|
)
|
|
|
|
action = {"parameters": {"action": "login"}}
|
|
replay_state = {
|
|
"last_screenshot_path": "/tmp/login_screen.png",
|
|
"screen_width": 1920,
|
|
"screen_height": 1080,
|
|
}
|
|
|
|
p1, p2, p3, p4 = _patch_all_deps(navigate_login_result=mock_result)
|
|
with p1, p2, p3, p4:
|
|
result = _handle_navigate_action(action, replay_state, "test-session")
|
|
|
|
assert result is True
|
|
vars_ = replay_state["variables"]
|
|
assert "navigate_login_coords" in vars_
|
|
assert vars_["navigate_login_coords"]["x_pct"] == 0.15
|
|
assert "navigate_password_coords" in vars_
|
|
assert "navigate_submit_coords" in vars_
|
|
assert vars_["navigate_result"]["all_resolved"] is True
|
|
|
|
|
|
class TestOcrMissVlmFail:
|
|
"""OCR misses target + VLM grounder also fails → no phantom coords."""
|
|
|
|
def test_no_phantom_coords_on_failure(self):
|
|
mock_result = NavigateResult(
|
|
login_coords=None,
|
|
password_coords=None,
|
|
submit_coords=None,
|
|
all_resolved=False,
|
|
error="grounding failed — no login form elements found",
|
|
)
|
|
|
|
action = {"parameters": {"action": "login"}}
|
|
replay_state = {
|
|
"last_screenshot_path": "/tmp/no_login_form.png",
|
|
"screen_width": 1920,
|
|
"screen_height": 1080,
|
|
}
|
|
|
|
p1, p2, p3, p4 = _patch_all_deps(navigate_login_result=mock_result)
|
|
with p1, p2, p3, p4:
|
|
result = _handle_navigate_action(action, replay_state, "test-session")
|
|
|
|
assert result is False
|
|
vars_ = replay_state["variables"]
|
|
# No coords keys should be present (coords are None → not stored)
|
|
assert "navigate_login_coords" not in vars_
|
|
assert "navigate_password_coords" not in vars_
|
|
assert "navigate_submit_coords" not in vars_
|
|
# Error must be non-empty
|
|
assert vars_["navigate_result"]["all_resolved"] is False
|
|
assert "grounding failed" in vars_["navigate_result"]["error"]
|
|
|
|
|
|
class TestNoScreenshot:
|
|
"""No screenshot in replay_state → error="no_screenshot", False."""
|
|
|
|
def test_no_screenshot_error(self):
|
|
action = {"parameters": {"action": "login"}}
|
|
replay_state = {} # No screenshot at all
|
|
|
|
result = _handle_navigate_action(action, replay_state, "test-session")
|
|
|
|
assert result is False
|
|
vars_ = replay_state["variables"]
|
|
assert vars_["navigate_login_coords"]["error"] == "no_screenshot"
|
|
|
|
def test_empty_screenshot_path(self):
|
|
action = {"parameters": {"action": "login"}}
|
|
replay_state = {"last_screenshot_path": ""}
|
|
|
|
result = _handle_navigate_action(action, replay_state, "test-session")
|
|
|
|
assert result is False
|
|
vars_ = replay_state["variables"]
|
|
assert vars_["navigate_login_coords"]["error"] == "no_screenshot"
|
|
|
|
|
|
class TestNeverFailReplay:
|
|
"""Handler must never raise — even on malformed input, returns False."""
|
|
|
|
def test_missing_parameters(self):
|
|
action = {} # No "parameters" key
|
|
replay_state = {"last_screenshot_path": "/tmp/x.png"}
|
|
|
|
mock_result = NavigateResult(all_resolved=False, error="no params")
|
|
p1, p2, p3, p4 = _patch_all_deps(navigate_login_result=mock_result)
|
|
with p1, p2, p3, p4:
|
|
result = _handle_navigate_action(action, replay_state, "test-session")
|
|
assert result is False
|
|
|
|
def test_exception_in_inner_call(self):
|
|
action = {"parameters": {"action": "login"}}
|
|
replay_state = {
|
|
"last_screenshot_path": "/tmp/login.png",
|
|
"screen_width": 1920,
|
|
"screen_height": 1080,
|
|
}
|
|
|
|
p1, p2, p3, p4 = _patch_all_deps(navigate_login_side_effect=RuntimeError("boom"))
|
|
with p1, p2, p3, p4:
|
|
result = _handle_navigate_action(action, replay_state, "test-session")
|
|
|
|
assert result is False
|
|
vars_ = replay_state["variables"]
|
|
assert vars_["navigate_result"]["all_resolved"] is False
|
|
assert "boom" in vars_["navigate_result"]["error"]
|