feat(cache): ScreenStateCache clé composite context-aware (Lot D)
Avant : clé = phash seul
-> deux contextes différents avec même screenshot partageaient
la même entrée cache -> collisions silencieuses.
Après : clé composite {phash}|{md5(ctx)[:16]} avec ctx =
- window_title
- app_name
- enable_ocr
- enable_ui_detection
- workflow_id (isolation inter-workflows)
get_or_compute() kwargs-only. TTL 2s et éviction LRU inchangés.
invalidate_if_changed() continue de comparer uniquement les phash.
ExecutionLoop propage tout le contexte au cache.
8 nouveaux tests prouvant :
- même image + window différent = miss
- même image + app différent = miss
- même image + flags différents = miss
- même image + workflow_id différent = miss
- même image + même contexte = hit
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
409
core/pipeline/screen_state_cache.py
Normal file
409
core/pipeline/screen_state_cache.py
Normal file
@@ -0,0 +1,409 @@
|
||||
"""
|
||||
ScreenStateCache — Cache perceptuel de ScreenState (context-aware).
|
||||
|
||||
Objectif : éviter de réanalyser un screenshot identique (5-15s VLM/OCR)
|
||||
à chaque step de la boucle d'exécution.
|
||||
|
||||
Principe (Lot D — avril 2026) :
|
||||
- Clé = composite de 6 éléments pour éviter les collisions silencieuses
|
||||
entre contextes différents partageant un même screenshot :
|
||||
1. phash (dhash 8x8 du screenshot) — calculé en ~2-5ms
|
||||
2. window_title (titre fenêtre active)
|
||||
3. app_name (nom process actif)
|
||||
4. enable_ocr (flag runtime)
|
||||
5. enable_ui_detection (flag runtime)
|
||||
6. workflow_id (isolation inter-workflows)
|
||||
- TTL par défaut : 2 secondes (configurable)
|
||||
- Invalidation explicite possible (par clé composite ou globale)
|
||||
- invalidate_if_changed reste piloté par le phash seul (détection de
|
||||
changement visuel majeur, indépendant du contexte)
|
||||
- Thread-safe (lock interne)
|
||||
|
||||
API principale :
|
||||
>>> cache = ScreenStateCache(ttl_seconds=2.0)
|
||||
>>> state, hit, ms = cache.get_or_compute(
|
||||
... screenshot_path, compute_fn,
|
||||
... window_title="App", app_name="app.exe",
|
||||
... enable_ocr=True, enable_ui_detection=True,
|
||||
... workflow_id="wf_123",
|
||||
... )
|
||||
|
||||
La fonction `compute_fn` prend le chemin du screenshot et doit retourner
|
||||
un `ScreenState`. Elle n'est appelée qu'en cache miss.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional, Tuple
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from core.models.screen_state import ScreenState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Hash perceptuel (dhash simple, sans dépendance imagehash)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _hamming_distance_hex(a: str, b: str) -> int:
|
||||
"""
|
||||
Distance de Hamming entre deux chaînes hexadécimales de même longueur.
|
||||
|
||||
Retourne le nombre de bits qui diffèrent entre les deux hashes.
|
||||
Si les longueurs diffèrent, on pad à droite par des zéros.
|
||||
"""
|
||||
if len(a) != len(b):
|
||||
max_len = max(len(a), len(b))
|
||||
a = a.ljust(max_len, "0")
|
||||
b = b.ljust(max_len, "0")
|
||||
try:
|
||||
xor = int(a, 16) ^ int(b, 16)
|
||||
return bin(xor).count("1")
|
||||
except ValueError:
|
||||
# Fallback : comparaison caractère à caractère
|
||||
return sum(1 for ca, cb in zip(a, b) if ca != cb) * 4
|
||||
|
||||
|
||||
def compute_perceptual_hash(screenshot_path: str, size: int = 8) -> str:
|
||||
"""
|
||||
Calculer un dhash (difference hash) pour un screenshot.
|
||||
|
||||
Algorithme :
|
||||
1. Convertir en niveaux de gris
|
||||
2. Redimensionner à (size+1) x size
|
||||
3. Comparer chaque pixel avec son voisin de droite (dhash)
|
||||
4. Retourner un hash hexadécimal de size*size bits
|
||||
|
||||
Robuste aux petites variations (curseur, blink, compression).
|
||||
Coût typique : 2-5 ms sur un 1920x1080.
|
||||
|
||||
Args:
|
||||
screenshot_path: Chemin vers le fichier image
|
||||
size: Taille du hash (8 = 64 bits, défaut)
|
||||
|
||||
Returns:
|
||||
Chaîne hexadécimale (size*size/4 caractères)
|
||||
"""
|
||||
try:
|
||||
img = Image.open(screenshot_path)
|
||||
img = img.convert("L").resize((size + 1, size), Image.LANCZOS)
|
||||
pixels = list(img.getdata())
|
||||
|
||||
# dhash : comparer chaque pixel avec celui de droite
|
||||
bits = []
|
||||
for row in range(size):
|
||||
for col in range(size):
|
||||
left = pixels[row * (size + 1) + col]
|
||||
right = pixels[row * (size + 1) + col + 1]
|
||||
bits.append(1 if left > right else 0)
|
||||
|
||||
# Convertir en hex
|
||||
value = 0
|
||||
for bit in bits:
|
||||
value = (value << 1) | bit
|
||||
return format(value, f"0{size * size // 4}x")
|
||||
except Exception as e:
|
||||
logger.warning(f"Hash perceptuel échoué pour {screenshot_path}: {e}")
|
||||
# Fallback : hash du contenu brut
|
||||
try:
|
||||
data = Path(screenshot_path).read_bytes()
|
||||
return hashlib.md5(data).hexdigest()[:16]
|
||||
except Exception:
|
||||
return f"unhashable_{int(time.time() * 1000)}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Clé composite (Lot D)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _make_cache_key(
|
||||
phash: str,
|
||||
window_title: str,
|
||||
app_name: str,
|
||||
enable_ocr: bool,
|
||||
enable_ui_detection: bool,
|
||||
workflow_id: str,
|
||||
) -> str:
|
||||
"""
|
||||
Construire une clé composite stable pour le cache.
|
||||
|
||||
Combine les 6 dimensions du contexte d'exécution dans une chaîne
|
||||
hexadécimale (md5 tronqué à 16 caractères), préfixée par le phash pour
|
||||
conserver une lisibilité minimale en debug (log : `aabb…|ctx=1234…`).
|
||||
|
||||
NB : On hash plutôt que concaténer brut pour :
|
||||
- Borner la taille de la clé même si window_title est long
|
||||
- Éviter les collisions triviales (séparateur présent dans un titre)
|
||||
- Rendre la clé opaque (pas de PII en clair dans les logs de cache)
|
||||
|
||||
Args:
|
||||
phash: Hash perceptuel du screenshot (dhash 8x8)
|
||||
window_title: Titre de la fenêtre active (str)
|
||||
app_name: Nom du process actif (str)
|
||||
enable_ocr: Flag runtime OCR (bool)
|
||||
enable_ui_detection: Flag runtime détection UI (bool)
|
||||
workflow_id: ID du workflow en cours (str, "" pour legacy)
|
||||
|
||||
Returns:
|
||||
Clé composite `{phash}|{ctx_hash}` où ctx_hash = md5(16)
|
||||
"""
|
||||
# Sérialisation déterministe ; `|` comme séparateur interne puisque hashé.
|
||||
ctx_repr = (
|
||||
f"{window_title or ''}\x1f"
|
||||
f"{app_name or ''}\x1f"
|
||||
f"{int(bool(enable_ocr))}\x1f"
|
||||
f"{int(bool(enable_ui_detection))}\x1f"
|
||||
f"{workflow_id or ''}"
|
||||
)
|
||||
ctx_hash = hashlib.md5(ctx_repr.encode("utf-8")).hexdigest()[:16]
|
||||
return f"{phash}|{ctx_hash}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Entry
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CacheEntry:
|
||||
state: ScreenState
|
||||
created_at: float
|
||||
phash: str # phash seul (utilisé par invalidate_if_changed)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Cache
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ScreenStateCache:
|
||||
"""
|
||||
Cache de ScreenState avec TTL et clé composite context-aware.
|
||||
|
||||
Thread-safe. Utilise un lock interne pour les opérations get/set.
|
||||
"""
|
||||
|
||||
def __init__(self, ttl_seconds: float = 2.0, max_entries: int = 16):
|
||||
"""
|
||||
Args:
|
||||
ttl_seconds: Durée de vie d'une entrée (en secondes)
|
||||
max_entries: Nombre max d'entrées avant éviction LRU simple
|
||||
"""
|
||||
self.ttl_seconds = ttl_seconds
|
||||
self.max_entries = max_entries
|
||||
# Clé = composite (_make_cache_key), valeur = _CacheEntry
|
||||
self._store: dict[str, _CacheEntry] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# Métriques simples (utile pour le debug / logs)
|
||||
self.hits = 0
|
||||
self.misses = 0
|
||||
self.invalidations = 0
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# API bas niveau (par clé composite)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _get(self, composite_key: str) -> Optional[ScreenState]:
|
||||
"""Retourne l'entrée pour cette clé composite si encore valide."""
|
||||
with self._lock:
|
||||
entry = self._store.get(composite_key)
|
||||
if entry is None:
|
||||
return None
|
||||
if time.time() - entry.created_at > self.ttl_seconds:
|
||||
# Expiré
|
||||
self._store.pop(composite_key, None)
|
||||
return None
|
||||
return entry.state
|
||||
|
||||
def _set(self, composite_key: str, phash: str, state: ScreenState) -> None:
|
||||
"""Enregistre un état pour cette clé composite."""
|
||||
with self._lock:
|
||||
# Éviction simple : si plein, virer l'entrée la plus ancienne
|
||||
if (
|
||||
len(self._store) >= self.max_entries
|
||||
and composite_key not in self._store
|
||||
):
|
||||
oldest_key = min(
|
||||
self._store, key=lambda k: self._store[k].created_at
|
||||
)
|
||||
self._store.pop(oldest_key, None)
|
||||
|
||||
self._store[composite_key] = _CacheEntry(
|
||||
state=state,
|
||||
created_at=time.time(),
|
||||
phash=phash,
|
||||
)
|
||||
|
||||
def invalidate(self, composite_key: Optional[str] = None) -> None:
|
||||
"""
|
||||
Invalider une entrée ou tout le cache.
|
||||
|
||||
Args:
|
||||
composite_key: Clé à invalider. Si None, vide tout le cache.
|
||||
"""
|
||||
with self._lock:
|
||||
if composite_key is None:
|
||||
self._store.clear()
|
||||
else:
|
||||
self._store.pop(composite_key, None)
|
||||
self.invalidations += 1
|
||||
|
||||
def invalidate_if_changed(
|
||||
self,
|
||||
screenshot_path: str,
|
||||
threshold: float = 0.3,
|
||||
) -> bool:
|
||||
"""
|
||||
Invalider le cache si l'écran a suffisamment changé.
|
||||
|
||||
Compare le dhash du screenshot courant avec le phash (seul) de chaque
|
||||
entrée du cache. La décision est volontairement indépendante du reste
|
||||
de la clé composite : un changement visuel majeur rend toutes les
|
||||
entrées obsolètes, quel que soit le contexte.
|
||||
|
||||
Args:
|
||||
screenshot_path: Chemin du screenshot courant
|
||||
threshold: Proportion de bits qui doivent différer (0.0-1.0).
|
||||
0.3 = 30% (~19 bits sur 64) = changement significatif.
|
||||
|
||||
Returns:
|
||||
True si le cache a été invalidé, False sinon.
|
||||
"""
|
||||
if not self._store:
|
||||
return False
|
||||
|
||||
current_phash = compute_perceptual_hash(screenshot_path)
|
||||
|
||||
# Bits totaux : 64 pour un dhash 8x8 standard. On déduit via la
|
||||
# longueur hexa du hash courant pour rester générique.
|
||||
total_bits = len(current_phash) * 4
|
||||
if total_bits == 0:
|
||||
return False
|
||||
|
||||
threshold_bits = threshold * total_bits
|
||||
|
||||
with self._lock:
|
||||
if not self._store:
|
||||
return False
|
||||
|
||||
# Distance de Hamming minimale avec les phashes des entrées
|
||||
# (on regarde entry.phash, pas la clé composite).
|
||||
min_distance = None
|
||||
for entry in self._store.values():
|
||||
distance = _hamming_distance_hex(current_phash, entry.phash)
|
||||
if min_distance is None or distance < min_distance:
|
||||
min_distance = distance
|
||||
|
||||
if min_distance is not None and min_distance > threshold_bits:
|
||||
size_before = len(self._store)
|
||||
self._store.clear()
|
||||
self.invalidations += 1
|
||||
logger.debug(
|
||||
f"[ScreenStateCache] invalidate_if_changed: "
|
||||
f"distance={min_distance}/{total_bits} > "
|
||||
f"threshold={threshold_bits:.1f} → {size_before} entrées purgées"
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# API haut niveau (context-aware)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def get_or_compute(
|
||||
self,
|
||||
screenshot_path: str,
|
||||
compute_fn: Callable[[str], ScreenState],
|
||||
*,
|
||||
window_title: str = "",
|
||||
app_name: str = "",
|
||||
enable_ocr: bool = True,
|
||||
enable_ui_detection: bool = True,
|
||||
workflow_id: str = "",
|
||||
force_refresh: bool = False,
|
||||
) -> Tuple[ScreenState, bool, float]:
|
||||
"""
|
||||
Récupérer ou calculer le ScreenState pour un screenshot + contexte.
|
||||
|
||||
Clé de cache = composite(phash, window_title, app_name, enable_ocr,
|
||||
enable_ui_detection, workflow_id). Deux contextes différents partageant
|
||||
le même screenshot n'entrent PAS en collision.
|
||||
|
||||
Rétrocompatibilité : tous les kwargs de contexte ont une valeur par
|
||||
défaut. Un caller legacy qui n'a pas encore été adapté partagera la
|
||||
même entrée de cache qu'un autre caller legacy (comportement antérieur).
|
||||
|
||||
Args:
|
||||
screenshot_path: Chemin du screenshot
|
||||
compute_fn: Fonction qui construit un ScreenState si cache miss
|
||||
window_title: Titre de la fenêtre active (contexte visuel)
|
||||
app_name: Nom du process actif (contexte applicatif)
|
||||
enable_ocr: Flag runtime — différencie états avec/sans OCR
|
||||
enable_ui_detection: Flag runtime — différencie états avec/sans UI
|
||||
workflow_id: ID du workflow — isolation inter-workflows
|
||||
force_refresh: Ignorer le cache et recalculer
|
||||
|
||||
Returns:
|
||||
Tuple (state, cache_hit, elapsed_ms)
|
||||
"""
|
||||
t0 = time.time()
|
||||
phash = compute_perceptual_hash(screenshot_path)
|
||||
composite_key = _make_cache_key(
|
||||
phash=phash,
|
||||
window_title=window_title,
|
||||
app_name=app_name,
|
||||
enable_ocr=enable_ocr,
|
||||
enable_ui_detection=enable_ui_detection,
|
||||
workflow_id=workflow_id,
|
||||
)
|
||||
|
||||
if not force_refresh:
|
||||
cached = self._get(composite_key)
|
||||
if cached is not None:
|
||||
self.hits += 1
|
||||
elapsed_ms = (time.time() - t0) * 1000
|
||||
logger.debug(
|
||||
f"[ScreenStateCache] HIT key={composite_key[:24]}… "
|
||||
f"({elapsed_ms:.1f}ms)"
|
||||
)
|
||||
return cached, True, elapsed_ms
|
||||
|
||||
# Cache miss → calcul complet
|
||||
self.misses += 1
|
||||
state = compute_fn(screenshot_path)
|
||||
self._set(composite_key, phash, state)
|
||||
elapsed_ms = (time.time() - t0) * 1000
|
||||
logger.debug(
|
||||
f"[ScreenStateCache] MISS key={composite_key[:24]}… "
|
||||
f"({elapsed_ms:.1f}ms)"
|
||||
)
|
||||
return state, False, elapsed_ms
|
||||
|
||||
def stats(self) -> dict:
|
||||
"""Retourne les métriques du cache."""
|
||||
with self._lock:
|
||||
total = self.hits + self.misses
|
||||
return {
|
||||
"hits": self.hits,
|
||||
"misses": self.misses,
|
||||
"invalidations": self.invalidations,
|
||||
"hit_rate": self.hits / total if total > 0 else 0.0,
|
||||
"size": len(self._store),
|
||||
"max_entries": self.max_entries,
|
||||
"ttl_seconds": self.ttl_seconds,
|
||||
}
|
||||
|
||||
def __len__(self) -> int:
|
||||
with self._lock:
|
||||
return len(self._store)
|
||||
449
tests/unit/test_screen_state_cache.py
Normal file
449
tests/unit/test_screen_state_cache.py
Normal file
@@ -0,0 +1,449 @@
|
||||
"""
|
||||
Tests unitaires du ScreenStateCache.
|
||||
|
||||
Couvre :
|
||||
- Hash perceptuel (déterministe, stable sur même image, différent sur autres)
|
||||
- Cache hit / miss
|
||||
- TTL (expiration)
|
||||
- Invalidation explicite
|
||||
- Éviction LRU
|
||||
- Thread-safety basique
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from core.models.screen_state import (
|
||||
ContextLevel,
|
||||
EmbeddingRef,
|
||||
PerceptionLevel,
|
||||
RawLevel,
|
||||
ScreenState,
|
||||
WindowContext,
|
||||
)
|
||||
from core.pipeline.screen_state_cache import (
|
||||
ScreenStateCache,
|
||||
compute_perceptual_hash,
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_screenshot(tmp_path: Path, color: tuple, name: str = "shot.png") -> str:
|
||||
img = Image.new("RGB", (320, 240), color=color)
|
||||
path = tmp_path / name
|
||||
img.save(str(path))
|
||||
return str(path)
|
||||
|
||||
|
||||
def _make_state(session_id: str = "s1") -> ScreenState:
|
||||
return ScreenState(
|
||||
screen_state_id=f"state_{datetime.now().strftime('%H%M%S%f')}",
|
||||
timestamp=datetime.now(),
|
||||
session_id=session_id,
|
||||
window=WindowContext(
|
||||
app_name="app", window_title="Title", screen_resolution=[1920, 1080]
|
||||
),
|
||||
raw=RawLevel(screenshot_path="", capture_method="test", file_size_bytes=0),
|
||||
perception=PerceptionLevel(
|
||||
embedding=EmbeddingRef(provider="t", vector_id="v", dimensions=512),
|
||||
detected_text=[],
|
||||
text_detection_method="none",
|
||||
confidence_avg=0.0,
|
||||
),
|
||||
context=ContextLevel(),
|
||||
ui_elements=[],
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Hash perceptuel
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPerceptualHash:
|
||||
|
||||
def test_deterministic_for_same_image(self, tmp_path):
|
||||
path = _make_screenshot(tmp_path, (255, 0, 0))
|
||||
h1 = compute_perceptual_hash(path)
|
||||
h2 = compute_perceptual_hash(path)
|
||||
assert h1 == h2
|
||||
assert len(h1) == 16 # 8*8 bits = 64 bits = 16 hex chars
|
||||
|
||||
def test_differs_across_images(self, tmp_path):
|
||||
path_red = _make_screenshot(tmp_path, (255, 0, 0), "red.png")
|
||||
path_blue = _make_screenshot(tmp_path, (0, 0, 255), "blue.png")
|
||||
# Note : deux images unies ont le même dhash (toutes différences nulles)
|
||||
# On doit utiliser des images avec un vrai gradient pour différer.
|
||||
grad_red = Image.new("RGB", (320, 240))
|
||||
for x in range(320):
|
||||
for y in range(240):
|
||||
grad_red.putpixel((x, y), (x % 256, 0, 0))
|
||||
grad_path = tmp_path / "grad_red.png"
|
||||
grad_red.save(str(grad_path))
|
||||
|
||||
h_red = compute_perceptual_hash(path_red)
|
||||
h_grad = compute_perceptual_hash(str(grad_path))
|
||||
assert h_red != h_grad
|
||||
|
||||
def test_robust_to_missing_file(self, tmp_path):
|
||||
# Chemin inexistant → fallback mais pas de crash
|
||||
h = compute_perceptual_hash(str(tmp_path / "does_not_exist.png"))
|
||||
assert isinstance(h, str)
|
||||
assert len(h) > 0
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Cache
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestScreenStateCache:
|
||||
|
||||
def test_get_or_compute_cache_miss_then_hit(self, tmp_path):
|
||||
cache = ScreenStateCache(ttl_seconds=10.0)
|
||||
path = _make_screenshot(tmp_path, (100, 100, 100))
|
||||
|
||||
calls = []
|
||||
|
||||
def compute(p):
|
||||
calls.append(p)
|
||||
return _make_state()
|
||||
|
||||
s1, hit1, _ = cache.get_or_compute(path, compute)
|
||||
s2, hit2, _ = cache.get_or_compute(path, compute)
|
||||
|
||||
assert hit1 is False
|
||||
assert hit2 is True
|
||||
assert len(calls) == 1
|
||||
assert s1 is s2 # Même objet retourné
|
||||
|
||||
def test_ttl_expiration(self, tmp_path):
|
||||
cache = ScreenStateCache(ttl_seconds=0.1)
|
||||
path = _make_screenshot(tmp_path, (50, 50, 50))
|
||||
|
||||
def compute(_):
|
||||
return _make_state()
|
||||
|
||||
cache.get_or_compute(path, compute)
|
||||
time.sleep(0.15)
|
||||
_, hit, _ = cache.get_or_compute(path, compute)
|
||||
assert hit is False # Expiré
|
||||
|
||||
def test_force_refresh_bypasses_cache(self, tmp_path):
|
||||
cache = ScreenStateCache(ttl_seconds=10.0)
|
||||
path = _make_screenshot(tmp_path, (10, 10, 10))
|
||||
cache.get_or_compute(path, lambda _: _make_state())
|
||||
_, hit, _ = cache.get_or_compute(
|
||||
path, lambda _: _make_state(), force_refresh=True
|
||||
)
|
||||
assert hit is False
|
||||
|
||||
def test_invalidate_all(self, tmp_path):
|
||||
cache = ScreenStateCache(ttl_seconds=10.0)
|
||||
path = _make_screenshot(tmp_path, (200, 200, 200))
|
||||
cache.get_or_compute(path, lambda _: _make_state())
|
||||
cache.invalidate()
|
||||
_, hit, _ = cache.get_or_compute(path, lambda _: _make_state())
|
||||
assert hit is False
|
||||
|
||||
def test_eviction_lru(self, tmp_path):
|
||||
cache = ScreenStateCache(ttl_seconds=10.0, max_entries=2)
|
||||
# Créer 3 images différentes (gradients différents pour hashes différents)
|
||||
paths = []
|
||||
for i, intensity in enumerate([30, 120, 220]):
|
||||
img = Image.new("RGB", (320, 240))
|
||||
for x in range(320):
|
||||
for y in range(240):
|
||||
img.putpixel((x, y), ((x + intensity) % 256, intensity, 0))
|
||||
p = tmp_path / f"grad_{i}.png"
|
||||
img.save(str(p))
|
||||
paths.append(str(p))
|
||||
|
||||
def compute(_):
|
||||
return _make_state()
|
||||
|
||||
cache.get_or_compute(paths[0], compute)
|
||||
time.sleep(0.01)
|
||||
cache.get_or_compute(paths[1], compute)
|
||||
time.sleep(0.01)
|
||||
cache.get_or_compute(paths[2], compute)
|
||||
# Le 1er doit avoir été évincé
|
||||
assert len(cache) == 2
|
||||
|
||||
def test_stats(self, tmp_path):
|
||||
cache = ScreenStateCache(ttl_seconds=10.0)
|
||||
path = _make_screenshot(tmp_path, (77, 77, 77))
|
||||
cache.get_or_compute(path, lambda _: _make_state())
|
||||
cache.get_or_compute(path, lambda _: _make_state())
|
||||
stats = cache.stats()
|
||||
assert stats["hits"] == 1
|
||||
assert stats["misses"] == 1
|
||||
assert stats["hit_rate"] == 0.5
|
||||
|
||||
def test_invalidate_if_changed_purges_on_big_change(self, tmp_path):
|
||||
"""Un screenshot très différent doit invalider tout le cache."""
|
||||
import random
|
||||
|
||||
cache = ScreenStateCache(ttl_seconds=10.0)
|
||||
# Image 1 : gradient doux
|
||||
img1 = Image.new("RGB", (320, 240))
|
||||
for y in range(240):
|
||||
for x in range(320):
|
||||
img1.putpixel((x, y), (y, y, y))
|
||||
p1 = tmp_path / "v.png"
|
||||
img1.save(str(p1))
|
||||
|
||||
# Image 2 : bruit aléatoire (structure radicalement différente)
|
||||
random.seed(42)
|
||||
img2 = Image.new("RGB", (320, 240))
|
||||
for y in range(240):
|
||||
for x in range(320):
|
||||
v = random.randint(0, 255)
|
||||
img2.putpixel((x, y), (v, v, v))
|
||||
p2 = tmp_path / "noise.png"
|
||||
img2.save(str(p2))
|
||||
|
||||
cache.get_or_compute(str(p1), lambda _: _make_state())
|
||||
assert len(cache) == 1
|
||||
|
||||
purged = cache.invalidate_if_changed(str(p2), threshold=0.3)
|
||||
assert purged is True
|
||||
assert len(cache) == 0
|
||||
|
||||
def test_invalidate_if_changed_keeps_cache_on_small_change(self, tmp_path):
|
||||
"""Un screenshot très proche ne doit PAS invalider le cache."""
|
||||
cache = ScreenStateCache(ttl_seconds=10.0)
|
||||
# Même gradient avec un léger bruit
|
||||
img1 = Image.new("RGB", (320, 240))
|
||||
for y in range(240):
|
||||
for x in range(320):
|
||||
img1.putpixel((x, y), ((x + y) % 256, 0, 0))
|
||||
p1 = tmp_path / "a.png"
|
||||
img1.save(str(p1))
|
||||
|
||||
img2 = img1.copy()
|
||||
# Bruit léger : changer seulement quelques pixels
|
||||
for i in range(5):
|
||||
img2.putpixel((i, 0), (255, 255, 255))
|
||||
p2 = tmp_path / "b.png"
|
||||
img2.save(str(p2))
|
||||
|
||||
cache.get_or_compute(str(p1), lambda _: _make_state())
|
||||
purged = cache.invalidate_if_changed(str(p2), threshold=0.3)
|
||||
assert purged is False
|
||||
assert len(cache) == 1
|
||||
|
||||
def test_invalidate_if_changed_empty_cache_is_noop(self, tmp_path):
|
||||
"""Sur cache vide, invalidate_if_changed ne doit rien faire."""
|
||||
cache = ScreenStateCache(ttl_seconds=10.0)
|
||||
p = _make_screenshot(tmp_path, (100, 100, 100))
|
||||
purged = cache.invalidate_if_changed(p, threshold=0.3)
|
||||
assert purged is False
|
||||
|
||||
def test_thread_safety(self, tmp_path):
|
||||
"""Lecture/écriture concurrentes ne doivent pas crasher."""
|
||||
cache = ScreenStateCache(ttl_seconds=10.0)
|
||||
path = _make_screenshot(tmp_path, (64, 64, 64))
|
||||
errors = []
|
||||
|
||||
def worker():
|
||||
try:
|
||||
for _ in range(20):
|
||||
cache.get_or_compute(path, lambda _: _make_state())
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
threads = [threading.Thread(target=worker) for _ in range(5)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert not errors
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Clé composite context-aware (Lot D)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCacheContextAware:
|
||||
"""Lot D — Le cache ne doit jamais hit entre deux contextes différents.
|
||||
|
||||
La clé composite combine 6 éléments : phash, window_title, app_name,
|
||||
enable_ocr, enable_ui_detection, workflow_id. Toute variation sur une
|
||||
de ces dimensions doit produire un cache miss, même si le screenshot
|
||||
(donc le phash) est strictement identique.
|
||||
"""
|
||||
|
||||
def test_same_image_different_window_miss(self, tmp_path):
|
||||
cache = ScreenStateCache(ttl_seconds=10.0)
|
||||
path = _make_screenshot(tmp_path, (60, 60, 60))
|
||||
|
||||
_, hit_a, _ = cache.get_or_compute(
|
||||
path,
|
||||
lambda _: _make_state(),
|
||||
window_title="Chrome",
|
||||
app_name="chrome.exe",
|
||||
workflow_id="wf1",
|
||||
)
|
||||
_, hit_b, _ = cache.get_or_compute(
|
||||
path,
|
||||
lambda _: _make_state(),
|
||||
window_title="Firefox", # Diffère
|
||||
app_name="chrome.exe",
|
||||
workflow_id="wf1",
|
||||
)
|
||||
assert hit_a is False
|
||||
assert hit_b is False # Contexte fenêtre différent → miss
|
||||
|
||||
def test_same_image_different_app_miss(self, tmp_path):
|
||||
cache = ScreenStateCache(ttl_seconds=10.0)
|
||||
path = _make_screenshot(tmp_path, (90, 90, 90))
|
||||
|
||||
cache.get_or_compute(
|
||||
path,
|
||||
lambda _: _make_state(),
|
||||
window_title="Doc.pdf",
|
||||
app_name="acrobat.exe",
|
||||
)
|
||||
_, hit, _ = cache.get_or_compute(
|
||||
path,
|
||||
lambda _: _make_state(),
|
||||
window_title="Doc.pdf",
|
||||
app_name="sumatra.exe", # Diffère
|
||||
)
|
||||
assert hit is False # app_name différent → miss
|
||||
|
||||
def test_same_image_different_flags_miss(self, tmp_path):
|
||||
cache = ScreenStateCache(ttl_seconds=10.0)
|
||||
path = _make_screenshot(tmp_path, (120, 120, 120))
|
||||
|
||||
# Run 1 : OCR actif
|
||||
cache.get_or_compute(
|
||||
path,
|
||||
lambda _: _make_state(),
|
||||
enable_ocr=True,
|
||||
enable_ui_detection=True,
|
||||
)
|
||||
# Run 2 : OCR désactivé → clé différente
|
||||
_, hit_ocr_off, _ = cache.get_or_compute(
|
||||
path,
|
||||
lambda _: _make_state(),
|
||||
enable_ocr=False,
|
||||
enable_ui_detection=True,
|
||||
)
|
||||
# Run 3 : UI désactivé → encore une autre clé
|
||||
_, hit_ui_off, _ = cache.get_or_compute(
|
||||
path,
|
||||
lambda _: _make_state(),
|
||||
enable_ocr=True,
|
||||
enable_ui_detection=False,
|
||||
)
|
||||
assert hit_ocr_off is False
|
||||
assert hit_ui_off is False
|
||||
|
||||
def test_same_image_different_workflow_miss(self, tmp_path):
|
||||
"""Isolation stricte inter-workflows : replay wf1 ≠ replay wf2."""
|
||||
cache = ScreenStateCache(ttl_seconds=10.0)
|
||||
path = _make_screenshot(tmp_path, (33, 77, 200))
|
||||
|
||||
cache.get_or_compute(
|
||||
path, lambda _: _make_state(), workflow_id="wf_alpha"
|
||||
)
|
||||
_, hit, _ = cache.get_or_compute(
|
||||
path, lambda _: _make_state(), workflow_id="wf_beta"
|
||||
)
|
||||
assert hit is False
|
||||
|
||||
def test_same_image_same_context_hit(self, tmp_path):
|
||||
"""Tout identique → hit (comportement cache nominal)."""
|
||||
cache = ScreenStateCache(ttl_seconds=10.0)
|
||||
path = _make_screenshot(tmp_path, (42, 42, 42))
|
||||
|
||||
kwargs = dict(
|
||||
window_title="Notepad",
|
||||
app_name="notepad.exe",
|
||||
enable_ocr=True,
|
||||
enable_ui_detection=True,
|
||||
workflow_id="wf_stable",
|
||||
)
|
||||
calls = []
|
||||
|
||||
def compute(p):
|
||||
calls.append(p)
|
||||
return _make_state()
|
||||
|
||||
_, hit1, _ = cache.get_or_compute(path, compute, **kwargs)
|
||||
_, hit2, _ = cache.get_or_compute(path, compute, **kwargs)
|
||||
assert hit1 is False
|
||||
assert hit2 is True
|
||||
assert len(calls) == 1
|
||||
|
||||
def test_default_context_is_stable(self, tmp_path):
|
||||
"""Rétrocompat : deux callers sans kwargs de contexte partagent
|
||||
la même entrée de cache (ancien comportement préservé)."""
|
||||
cache = ScreenStateCache(ttl_seconds=10.0)
|
||||
path = _make_screenshot(tmp_path, (11, 22, 33))
|
||||
|
||||
calls = []
|
||||
|
||||
def compute(p):
|
||||
calls.append(p)
|
||||
return _make_state()
|
||||
|
||||
# Deux appels sans kwargs → doivent partager la même clé
|
||||
_, hit1, _ = cache.get_or_compute(path, compute)
|
||||
_, hit2, _ = cache.get_or_compute(path, compute)
|
||||
assert hit1 is False
|
||||
assert hit2 is True
|
||||
assert len(calls) == 1
|
||||
|
||||
def test_invalidate_if_changed_ignores_context(self, tmp_path):
|
||||
"""invalidate_if_changed regarde le phash seul, pas la clé composite.
|
||||
Un changement visuel majeur purge toutes les entrées, quel que soit
|
||||
leur contexte (workflow, flags, fenêtre)."""
|
||||
import random
|
||||
|
||||
cache = ScreenStateCache(ttl_seconds=10.0)
|
||||
|
||||
# Deux entrées dans des contextes différents MAIS pour la même image.
|
||||
img1 = Image.new("RGB", (320, 240))
|
||||
for y in range(240):
|
||||
for x in range(320):
|
||||
img1.putpixel((x, y), (y, y, y))
|
||||
p1 = tmp_path / "orig.png"
|
||||
img1.save(str(p1))
|
||||
|
||||
cache.get_or_compute(
|
||||
str(p1), lambda _: _make_state(), workflow_id="wf1"
|
||||
)
|
||||
cache.get_or_compute(
|
||||
str(p1), lambda _: _make_state(), workflow_id="wf2"
|
||||
)
|
||||
assert len(cache) == 2
|
||||
|
||||
# Nouveau screenshot radicalement différent → doit tout purger.
|
||||
random.seed(42)
|
||||
img2 = Image.new("RGB", (320, 240))
|
||||
for y in range(240):
|
||||
for x in range(320):
|
||||
v = random.randint(0, 255)
|
||||
img2.putpixel((x, y), (v, v, v))
|
||||
p2 = tmp_path / "noise.png"
|
||||
img2.save(str(p2))
|
||||
|
||||
purged = cache.invalidate_if_changed(str(p2), threshold=0.3)
|
||||
assert purged is True
|
||||
assert len(cache) == 0
|
||||
Reference in New Issue
Block a user