""" 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