Files
rpa_vision_v3/tests/unit/test_screen_analyzer.py
Dom 9ca277a63f refactor(pipeline): ScreenAnalyzer thread-safe et isolé (Lot C)
Retrait de l'état global toxique :
  - analyze() : kwargs-only enable_ocr, enable_ui_detection, session_id
  - Ne mute JAMAIS self pour les flags (variables locales + branches)
  - _resolve_ocr_instance() / _resolve_ui_detector_instance() : lecture seule
  - _init_lock par instance pour lazy init concurrent safe
  - session_id par appel, plus via mutation singleton

Avant : ExecutionLoop mutait analyzer._ocr, _ui_detector,
_ocr_initialized, _ui_detector_initialized pour désactiver OCR/UI.
Deux loops partageant le singleton se polluaient mutuellement.

Après : deux loops partageant l'analyzer sont complètement isolés.
Preuve par TestAnalyzerIsolationBetweenLoops (3 tests).

Singleton get_screen_analyzer() préservé — garde uniquement les
ressources lourdes, plus de contexte d'exécution.

9 nouveaux tests (3 isolation + 6 kwargs-only/lazy-init).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:06:41 +02:00

186 lines
7.0 KiB
Python

"""
Tests unitaires de `ScreenAnalyzer` (Lot C — thread-safety).
Couvre :
- Les flags runtime sont kwargs-only (enable_ocr, enable_ui_detection, session_id)
- L'init lazy (OCR + UIDetector) est protégée par un lock → pas de double init
- `analyze()` ne mute jamais `_ocr*` / `_ui_detector*` pour gérer les flags
"""
from __future__ import annotations
import threading
import time
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from PIL import Image
from core.pipeline.screen_analyzer import ScreenAnalyzer
@pytest.fixture
def screenshot(tmp_path):
path = tmp_path / "shot.png"
Image.new("RGB", (64, 64), color=(100, 100, 100)).save(str(path))
return str(path)
# -----------------------------------------------------------------------------
# API — kwargs-only
# -----------------------------------------------------------------------------
class TestAnalyzeKwargsOnly:
"""Les flags runtime doivent être passés en kwargs-only, jamais positionnels."""
def test_analyze_kwargs_only_accept(self, screenshot):
"""L'appel nominal avec kwargs fonctionne."""
analyzer = ScreenAnalyzer()
# Empêcher l'init réelle
analyzer._ocr = None
analyzer._ocr_initialized = True
analyzer._ui_detector = None
analyzer._ui_detector_initialized = True
state = analyzer.analyze(
screenshot,
enable_ocr=False,
enable_ui_detection=False,
session_id="s_kwargs",
)
assert state.session_id == "s_kwargs"
assert state.perception.detected_text == []
assert state.ui_elements == []
def test_analyze_rejects_positional_flags(self, screenshot):
"""Passer enable_ocr en position 4 (après window_info, context) → TypeError."""
analyzer = ScreenAnalyzer()
analyzer._ocr = None
analyzer._ocr_initialized = True
analyzer._ui_detector = None
analyzer._ui_detector_initialized = True
# Signature : analyze(self, screenshot_path, window_info=None, context=None,
# *, enable_ocr=..., enable_ui_detection=..., session_id=...)
# Un 4e argument positionnel doit être rejeté.
with pytest.raises(TypeError):
analyzer.analyze(screenshot, None, None, False) # noqa: E501 (flag positionnel interdit)
def test_analyze_session_id_propagates_to_state(self, screenshot):
"""session_id passé en kwarg remplit ScreenState.session_id et metadata."""
analyzer = ScreenAnalyzer(session_id="default_session")
analyzer._ocr = None
analyzer._ocr_initialized = True
analyzer._ui_detector = None
analyzer._ui_detector_initialized = True
# kwarg explicite → prioritaire
state_call = analyzer.analyze(screenshot, session_id="explicit_session")
assert state_call.session_id == "explicit_session"
assert state_call.metadata["session_id"] == "explicit_session"
# kwarg vide → fallback sur la valeur d'instance (rétrocompat)
state_default = analyzer.analyze(screenshot)
assert state_default.session_id == "default_session"
# -----------------------------------------------------------------------------
# Lazy init sous lock
# -----------------------------------------------------------------------------
class TestLazyInitUnderLock:
"""L'init lazy (OCR / UIDetector) ne doit jamais se faire en double."""
def test_analyze_lazy_init_under_lock(self, screenshot):
"""Init concurrente → une seule création de l'OCR."""
analyzer = ScreenAnalyzer()
# Simuler un init OCR coûteux : compte les appels, renvoie un OCR factice.
init_count = {"n": 0}
def fake_ensure_ocr_locked():
# Ne marcher qu'une fois : mimer _ensure_ocr_locked qui s'auto-verrouille.
init_count["n"] += 1
time.sleep(0.05) # laisser la concurrence s'exprimer
analyzer._ocr = lambda p: ["ok"]
analyzer._ocr_initialized = True
analyzer._ensure_ocr_locked = fake_ensure_ocr_locked # type: ignore[assignment]
# UIDetector déjà "prêt" (pas None → détection évitée via mock)
analyzer._ui_detector = None
analyzer._ui_detector_initialized = True
# N threads lancent analyze() simultanément
results = []
errors = []
def worker():
try:
s = analyzer.analyze(screenshot, enable_ocr=True, enable_ui_detection=False)
results.append(s)
except Exception as e: # pragma: no cover
errors.append(e)
threads = [threading.Thread(target=worker) for _ in range(8)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=10)
assert not errors, f"Erreurs dans les threads: {errors}"
assert len(results) == 8
# UNE seule init OCR malgré 8 appels concurrents
assert init_count["n"] == 1, (
f"Init OCR exécutée {init_count['n']} fois — doit être 1 sous lock"
)
def test_analyze_no_mutation_for_flag_bypass(self, screenshot):
"""enable_ocr=False NE DOIT PAS muter self._ocr ni _ocr_initialized."""
analyzer = ScreenAnalyzer()
# État "frais" : rien d'initialisé
assert analyzer._ocr is None
assert analyzer._ocr_initialized is False
assert analyzer._ui_detector is None
assert analyzer._ui_detector_initialized is False
analyzer.analyze(screenshot, enable_ocr=False, enable_ui_detection=False)
# L'état interne doit être strictement inchangé : aucune init n'a été
# déclenchée puisque les deux flags étaient à False.
assert analyzer._ocr is None
assert analyzer._ocr_initialized is False
assert analyzer._ui_detector is None
assert analyzer._ui_detector_initialized is False
def test_analyze_lazy_init_only_when_requested(self, screenshot):
"""enable_ocr=True sur instance fraîche → init déclenchée.
enable_ocr=False sur instance fraîche → pas d'init."""
analyzer = ScreenAnalyzer()
calls = {"ocr": 0, "ui": 0}
def fake_ocr_init():
calls["ocr"] += 1
analyzer._ocr = lambda p: []
analyzer._ocr_initialized = True
def fake_ui_init():
calls["ui"] += 1
analyzer._ui_detector = None
analyzer._ui_detector_initialized = True
analyzer._ensure_ocr_locked = fake_ocr_init # type: ignore[assignment]
analyzer._ensure_ui_detector_locked = fake_ui_init # type: ignore[assignment]
# Appel 1 : seul OCR demandé
analyzer.analyze(screenshot, enable_ocr=True, enable_ui_detection=False)
assert calls["ocr"] == 1
assert calls["ui"] == 0
# Appel 2 : maintenant UI demandée
analyzer.analyze(screenshot, enable_ocr=True, enable_ui_detection=True)
assert calls["ocr"] == 1 # déjà initialisé, pas de réinit
assert calls["ui"] == 1