- 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>
206 lines
7.6 KiB
Python
206 lines
7.6 KiB
Python
"""Tests for core/navigation/action_resolver.py — coordinate conversion + OCR adapters."""
|
|
|
|
import json
|
|
import pytest
|
|
from core.navigation.action_resolver import (
|
|
NavigateCoords,
|
|
NavigateResult,
|
|
grounded_to_coords,
|
|
make_ocr_simple_from_detailed,
|
|
navigate_login,
|
|
)
|
|
from core.navigation.grounding import (
|
|
CoordsCache,
|
|
GroundedElement,
|
|
OcrTokenInfo,
|
|
OcrDetailedClient,
|
|
)
|
|
from core.navigation.visual_verifier import VlmClient
|
|
|
|
|
|
# ── Mock factories ─────────────────────────────────────────────────────
|
|
|
|
|
|
def mock_ocr_detailed_client_factory(tokens: list):
|
|
def client(image_path: str) -> list:
|
|
return tokens
|
|
return client
|
|
|
|
|
|
def mock_vlm_client_factory(response_json: dict):
|
|
def client(image_path: str, prompt: str) -> str:
|
|
return json.dumps(response_json)
|
|
return client
|
|
|
|
|
|
# ── grounded_to_coords tests ───────────────────────────────────────────
|
|
|
|
|
|
class TestGroundedToCoords:
|
|
def test_basic_conversion(self):
|
|
el = GroundedElement(
|
|
role="bouton", text="Connexion",
|
|
bbox=(200, 50, 400, 100), center=(300, 75),
|
|
confidence=0.9, method="ocr_anchor",
|
|
)
|
|
coords = grounded_to_coords(el, 1920, 1080)
|
|
assert coords.x_pct == pytest.approx(300 / 1920, abs=0.01)
|
|
assert coords.y_pct == pytest.approx(75 / 1080, abs=0.01)
|
|
assert coords.method == "ocr_anchor"
|
|
assert coords.bbox_pct is not None
|
|
|
|
def test_to_dict(self):
|
|
coords = NavigateCoords(x_pct=0.15, y_pct=0.07, method="ocr_anchor")
|
|
d = coords.to_dict()
|
|
assert d["x_pct"] == 0.15
|
|
assert d["y_pct"] == 0.07
|
|
assert d["method"] == "ocr_anchor"
|
|
|
|
def test_to_dict_with_bbox(self):
|
|
coords = NavigateCoords(
|
|
x_pct=0.15, y_pct=0.07,
|
|
bbox_pct=(0.10, 0.05, 0.20, 0.09),
|
|
method="vlm_grounder",
|
|
)
|
|
d = coords.to_dict()
|
|
assert "bbox_pct" in d
|
|
assert len(d["bbox_pct"]) == 4
|
|
|
|
|
|
# ── make_ocr_simple_from_detailed tests ────────────────────────────────
|
|
|
|
|
|
class TestMakeOcrSimpleFromDetailed:
|
|
def test_conversion(self):
|
|
tokens = [
|
|
OcrTokenInfo(text="Login", bbox=(100, 50, 250, 90)),
|
|
OcrTokenInfo(text="Password", bbox=(100, 100, 250, 140)),
|
|
]
|
|
detailed = mock_ocr_detailed_client_factory(tokens)
|
|
simple = make_ocr_simple_from_detailed(detailed)
|
|
result = simple("/tmp/test.png")
|
|
assert result == ["Login", "Password"]
|
|
|
|
def test_empty_tokens(self):
|
|
detailed = mock_ocr_detailed_client_factory([])
|
|
simple = make_ocr_simple_from_detailed(detailed)
|
|
result = simple("/tmp/test.png")
|
|
assert result == []
|
|
|
|
|
|
# ── navigate_login tests ───────────────────────────────────────────────
|
|
|
|
|
|
class TestNavigateLogin:
|
|
def test_full_success(self):
|
|
"""All fields grounded → NavigateResult with coords."""
|
|
ocr = mock_ocr_detailed_client_factory([
|
|
OcrTokenInfo(text="Login", bbox=(100, 50, 250, 90), confidence=0.95),
|
|
OcrTokenInfo(text="Mot de passe", bbox=(100, 100, 250, 140), confidence=0.95),
|
|
OcrTokenInfo(text="Connexion", bbox=(100, 150, 250, 190), confidence=0.95),
|
|
])
|
|
vlm = mock_vlm_client_factory({
|
|
"confirmed": [
|
|
{"index": 1, "role_confirmed": True, "actual_role": "champ", "confidence": 0.9},
|
|
{"index": 2, "role_confirmed": True, "actual_role": "champ", "confidence": 0.9},
|
|
{"index": 3, "role_confirmed": True, "actual_role": "bouton", "confidence": 0.9},
|
|
],
|
|
"overall_confidence": 0.9,
|
|
})
|
|
result = navigate_login(
|
|
"/tmp/login.png",
|
|
ocr_client=ocr, vlm_client=vlm,
|
|
skip_pre_verify=True,
|
|
)
|
|
assert result.all_resolved == True
|
|
assert result.login_coords is not None
|
|
assert result.password_coords is not None
|
|
assert result.submit_coords is not None
|
|
assert result.submit_coords.x_pct > 0
|
|
assert result.submit_coords.y_pct > 0
|
|
|
|
def test_no_clients_error(self):
|
|
"""Missing OCR/VLM clients → error."""
|
|
result = navigate_login("/tmp/login.png", ocr_client=None, vlm_client=None)
|
|
assert result.all_resolved == False
|
|
assert "required" in result.error
|
|
|
|
def test_pre_verify_fail(self):
|
|
"""Pre-verify fails → early abort."""
|
|
ocr = mock_ocr_detailed_client_factory([
|
|
OcrTokenInfo(text="Accueil", bbox=(0, 0, 100, 40)),
|
|
])
|
|
vlm = mock_vlm_client_factory({})
|
|
result = navigate_login(
|
|
"/tmp/page.png",
|
|
ocr_client=ocr, vlm_client=vlm,
|
|
skip_pre_verify=False,
|
|
)
|
|
assert result.all_resolved == False
|
|
assert result.pre_verify is not None
|
|
assert result.pre_verify.match == False
|
|
|
|
def test_skip_pre_verify(self):
|
|
"""Skip pre-verify → proceed to grounding even if form incomplete."""
|
|
ocr = mock_ocr_detailed_client_factory([
|
|
OcrTokenInfo(text="Login", bbox=(100, 50, 250, 90)),
|
|
OcrTokenInfo(text="Mot de passe", bbox=(100, 100, 250, 140)),
|
|
OcrTokenInfo(text="Connexion", bbox=(100, 150, 250, 190)),
|
|
])
|
|
vlm = mock_vlm_client_factory({})
|
|
result = navigate_login(
|
|
"/tmp/login.png",
|
|
ocr_client=ocr, vlm_client=vlm,
|
|
skip_pre_verify=True,
|
|
)
|
|
assert result.pre_verify is None # skipped
|
|
assert result.all_resolved == True
|
|
|
|
|
|
# ── NavigateResult dataclass tests ─────────────────────────────────────
|
|
|
|
|
|
class TestNavigateResult:
|
|
def test_default(self):
|
|
result = NavigateResult()
|
|
assert result.all_resolved == False
|
|
assert result.login_coords is None
|
|
assert result.error == ""
|
|
|
|
def test_with_coords(self):
|
|
result = NavigateResult(
|
|
login_coords=NavigateCoords(x_pct=0.15, y_pct=0.07, method="ocr_anchor"),
|
|
all_resolved=True,
|
|
)
|
|
assert result.login_coords.x_pct == 0.15
|
|
|
|
|
|
# ── Import validation ──────────────────────────────────────────────────
|
|
|
|
|
|
class TestImportValidation:
|
|
def test_action_resolver_imports(self):
|
|
"""Verify action_resolver module imports cleanly."""
|
|
from core.navigation.action_resolver import (
|
|
NavigateCoords,
|
|
NavigateResult,
|
|
grounded_to_coords,
|
|
make_ocr_detailed_from_grid,
|
|
make_ocr_simple_from_detailed,
|
|
navigate_login,
|
|
)
|
|
assert NavigateCoords is not None
|
|
assert NavigateResult is not None
|
|
|
|
def test_navigation_package_handler(self):
|
|
"""Verify _handle_navigate_action is importable from package."""
|
|
from core.navigation import _handle_navigate_action
|
|
assert callable(_handle_navigate_action)
|
|
|
|
def test_navigation_package_exports(self):
|
|
"""Verify package __all__ includes navigate exports."""
|
|
import core.navigation as nav
|
|
assert "navigate_login" in nav.__all__
|
|
assert "NavigateResult" in nav.__all__
|
|
assert "_handle_navigate_action" in nav.__all__
|