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>
186 lines
7.0 KiB
Python
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
|