Package core/validation/ minimal : - result.py : Verdict, FailureCategory, ValidationResult - pixel_diff_checker.py : wrapper de ReplayVerifier.verify_action - ocr_roi_checker.py : ROI 80px autour du clic, détecte WRONG_APPLICATION via SUSPECT_TOKENS (edge/https/explorateur de fichiers/…) - orchestrator.py : Validator dispatch action_type → checkers + agrégation Wiring api_stream.py:3646 derrière RPA_VALIDATOR_V2_ENABLED (OFF par défaut). Si verdict ≠ COMPLETE, override report.success=False et expose failure_category dans result_entry. Zero régression flag OFF. Tests : - tests/unit/test_validator_v2.py : 13 tests (Checkers + Validator + sérialisation) - tests/integration/test_validator_step10.py : 2 tests reproduisant le bug replay_sess_4c38dbb8 / act_raw_6c1432b3 (clic Enregistrer fait basculer vers Explorateur de fichiers) — Validator retourne WRONG_APPLICATION Activation pour test live : RPA_VALIDATOR_V2_ENABLED=true Cf. docs/recherche/SPEC_VALIDATOR_MATRICE.md, AXE_B2_DEEP_VALIDATOR.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
131 lines
4.8 KiB
Python
131 lines
4.8 KiB
Python
"""Repro offline du bug fonctionnel : replay_sess_4c38dbb8 / act_raw_6c1432b3.
|
||
|
||
L'agent rapporte success=True après avoir cliqué sur le bouton "Enregistrer"
|
||
du dialog "Enregistrer sous", mais la fenêtre active après le clic est
|
||
"rpa_vision : Explorateur de fichiers" — l'app a basculé hors du Bloc-notes.
|
||
|
||
Le Validator MVP P0 doit attribuer failure_category=WRONG_APPLICATION via
|
||
OcrRoiChecker (token suspect 'explorateur de fichiers' dans la ROI) et donc
|
||
override success → False.
|
||
|
||
Stratégie de fixture :
|
||
- screenshot_after synthétique : 800×600 avec "rpa_vision : Explorateur de fichiers"
|
||
au centre (= bug observé : la fenêtre est passée à l'Explorateur).
|
||
- screenshot_before : dialog "Enregistrer sous" (texte centré).
|
||
- action : click_anchor sur "Enregistrer" au centre (x_pct=0.5, y_pct=0.5).
|
||
- OCR injecté : fake qui retourne le texte du screenshot_after.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
import io
|
||
|
||
import pytest
|
||
|
||
pytestmark = [pytest.mark.integration]
|
||
|
||
|
||
def _png_b64(img) -> str:
|
||
buf = io.BytesIO()
|
||
img.save(buf, format="PNG")
|
||
return base64.b64encode(buf.getvalue()).decode("ascii")
|
||
|
||
|
||
def _make_screenshot(text: str, color=(245, 245, 245), size=(1920, 1080)):
|
||
"""Screenshot 1920x1080 avec un texte centré (visible dans la ROI 80px)."""
|
||
from PIL import Image, ImageDraw
|
||
img = Image.new("RGB", size, color=color)
|
||
draw = ImageDraw.Draw(img)
|
||
cx, cy = size[0] // 2, size[1] // 2
|
||
draw.text((cx - 200, cy - 8), text, fill=(0, 0, 0))
|
||
return img
|
||
|
||
|
||
@pytest.fixture
|
||
def bug_step10_fixtures():
|
||
"""Reproduit la situation act_raw_6c1432b3 sans OCR réel.
|
||
|
||
L'OCR est mocké pour retourner ce que verrait EasyOCR sur le screenshot after.
|
||
"""
|
||
before = _png_b64(_make_screenshot("Enregistrer sous"))
|
||
after = _png_b64(_make_screenshot("rpa_vision : Explorateur de fichiers"))
|
||
|
||
action = {
|
||
"type": "click",
|
||
"action_id": "act_raw_6c1432b3",
|
||
"by_text": "Enregistrer",
|
||
"target_spec": {
|
||
"by_text": "Enregistrer",
|
||
"window_title": "Enregistrer sous",
|
||
},
|
||
# Position normalisée au centre du screen (où le bouton "Enregistrer"
|
||
# était attendu d'après replay_sess_4c38dbb8.failures.jsonl)
|
||
"x_pct": 0.5289,
|
||
"y_pct": 0.7913,
|
||
}
|
||
# L'agent rapporte success=True (c'est le bug : pixel-diff legacy ne discrimine pas)
|
||
result = {
|
||
"success": True,
|
||
"actual_position": {"x_pct": 0.5289, "y_pct": 0.7913},
|
||
}
|
||
return before, after, action, result
|
||
|
||
|
||
def test_validator_detects_wrong_application_on_act_raw_6c1432b3(bug_step10_fixtures):
|
||
"""Le Validator doit retourner WRONG_APPLICATION malgré success=True client."""
|
||
from core.validation import OcrRoiChecker, Validator, Verdict, FailureCategory
|
||
|
||
before, after, action, result = bug_step10_fixtures
|
||
|
||
# OCR fake : on simule que EasyOCR lit dans la ROI le titre de la fenêtre
|
||
# active après le clic (l'Explorateur de fichiers a pris le focus).
|
||
def fake_ocr(crop):
|
||
# On suppose que la ROI 80×80 autour du clic au milieu-bas tombe
|
||
# sur la zone du texte. Pour le test, on retourne directement le
|
||
# texte qui ferait foi.
|
||
return "rpa_vision : Explorateur de fichiers"
|
||
|
||
ocr_click = OcrRoiChecker(ocr_fn=fake_ocr, radius_px=80)
|
||
# Construit le même Validator que api_stream._get_validator_v2()
|
||
validator = Validator(checkers={"click": [ocr_click]})
|
||
|
||
vr = validator.validate(
|
||
action=action,
|
||
result=result,
|
||
screenshot_before=before,
|
||
screenshot_after=after,
|
||
context={},
|
||
)
|
||
|
||
# Verdict attendu : TERMINATE / WRONG_APPLICATION (token 'explorateur de fichiers')
|
||
assert vr.verdict == Verdict.TERMINATE, (
|
||
f"Verdict attendu TERMINATE, obtenu {vr.verdict} (reasoning={vr.reasoning})"
|
||
)
|
||
assert vr.failure_category == FailureCategory.WRONG_APPLICATION
|
||
assert vr.confidence >= 0.85
|
||
assert "explorateur" in vr.reasoning.lower() or "explorateur" in vr.raw_evidence.get("roi_text", "").lower()
|
||
|
||
|
||
def test_validator_complete_when_correct_window_active(bug_step10_fixtures):
|
||
"""Sanity : si l'OCR voit bien 'Enregistrer' dans la ROI, le verdict est COMPLETE."""
|
||
from core.validation import OcrRoiChecker, Validator, Verdict
|
||
|
||
before, after_bad, action, result = bug_step10_fixtures
|
||
after_good = _png_b64(_make_screenshot("Document enregistre - Bloc-notes"))
|
||
|
||
def fake_ocr(crop):
|
||
return "Bouton Enregistrer cliqué — Bloc-notes"
|
||
|
||
validator = Validator(
|
||
checkers={"click": [OcrRoiChecker(ocr_fn=fake_ocr, radius_px=80)]},
|
||
)
|
||
vr = validator.validate(
|
||
action=action,
|
||
result=result,
|
||
screenshot_before=before,
|
||
screenshot_after=_png_b64(_make_screenshot("après save Bloc-notes")),
|
||
context={},
|
||
)
|
||
assert vr.verdict == Verdict.COMPLETE
|
||
assert vr.failure_category is None
|