Files
rpa_vision_v3/tests/unit/test_clip_embedder_device_fix.py

109 lines
4.2 KiB
Python

"""Tests de non-régression pour le fix UnboundLocalError sur 'torch'.
Cas couvert : appel `CLIPEmbedder(device="cpu")` explicite — le if `device is
None` n'était pas pris, donc l'import local `torch` n'était pas exécuté, mais
Python avait quand même noté `torch` comme local au scope `__init__`, faisant
planter `with torch.no_grad():` plus bas en UnboundLocalError.
Référence : inbox_codex/2026-05-25_1235_..._enquete-feedbackbus-5004.md
Fix : core/embedding/clip_embedder.py l. 60-77 (import local supprimé).
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
@pytest.mark.unit
def test_clip_embedder_init_no_local_torch_shadow():
"""Le source de CLIPEmbedder.__init__ ne contient plus 'import torch' à
l'intérieur du `if device is None:` (qui shadowait le torch module-level)."""
import inspect
from core.embedding import clip_embedder
src = inspect.getsource(clip_embedder.CLIPEmbedder.__init__)
# Tolérance : on accepte qu'un commentaire mentionne `import torch`,
# mais pas une vraie ligne d'instruction.
code_lines = [
line for line in src.splitlines()
if line.strip() and not line.strip().startswith("#")
]
code_only = "\n".join(code_lines)
# On ne doit plus avoir un import torch indenté au-delà du module-level.
# (l'import existe au top du fichier l. 8, pas dans __init__).
assert " import torch" not in code_only, (
"import torch local trouvé dans __init__ — il faut utiliser le torch "
"du scope module (l. 8 du fichier) pour éviter UnboundLocalError "
"quand l'appelant passe device='cpu'."
)
@pytest.mark.unit
def test_clip_embedder_module_imports_torch():
"""Le module clip_embedder doit avoir `import torch` au scope module
pour que les autres méthodes (embed_image, embed_text) puissent l'utiliser."""
import core.embedding.clip_embedder as ce
assert hasattr(ce, "torch"), (
"Le module clip_embedder doit exposer `torch` au scope module."
)
@pytest.mark.unit
def test_clip_embedder_handles_device_cpu_without_unbound_local(monkeypatch):
"""Reproduit le cas qui plantait : on appelle l'init avec device='cpu'.
Avant fix : UnboundLocalError sur `torch` au moment de `torch.no_grad()`.
Après fix : l'init doit échouer proprement sur l'absence éventuelle de
open_clip ou de poids, mais PAS sur UnboundLocalError.
On mocke open_clip et torch.no_grad pour ne pas charger un vrai modèle.
"""
import types
from core.embedding import clip_embedder
# Mock open_clip pour éviter le download
fake_open_clip = types.SimpleNamespace(
create_model_and_transforms=lambda *a, **kw: (
types.SimpleNamespace(
eval=lambda: None,
encode_image=lambda x: type("T", (), {"shape": (1, 512)})(),
),
None,
lambda img: img,
),
get_tokenizer=lambda name: lambda t: None,
)
monkeypatch.setattr(clip_embedder, "open_clip", fake_open_clip)
# Mock torch.no_grad et torch.zeros pour court-circuiter le dummy embed
class _FakeCtx:
def __enter__(self): return self
def __exit__(self, *a): return False
fake_zeros = lambda *args, **kwargs: type("Z", (), {"to": lambda self, d: self})()
monkeypatch.setattr(clip_embedder.torch, "no_grad", lambda: _FakeCtx())
monkeypatch.setattr(clip_embedder.torch, "zeros", fake_zeros)
# Appel direct avec device="cpu" — ne doit PAS lever UnboundLocalError.
# Peut échouer pour autre raison (ex. encode_image), on isole uniquement
# le bug torch unbound.
try:
embedder = clip_embedder.CLIPEmbedder(device="cpu")
except RuntimeError as e:
msg = str(e)
assert "cannot access local variable 'torch'" not in msg, (
f"UnboundLocalError torch toujours présent : {msg}"
)
# Autre erreur acceptée (mock incomplet)
pytest.skip(f"Mock incomplet, mais bug torch absent : {msg}")
except UnboundLocalError as e:
pytest.fail(f"Bug torch toujours présent : {e}")
# Si on arrive ici, init a réussi sans bug torch