import sys import types import importlib import pytest from pathlib import Path import importlib.util class DummyLogger: def info(self, *a, **k): pass def warning(self, *a, **k): pass def error(self, *a, **k): pass class DummySession: def __init__(self): self.record = None def exec(self, *a, **k): record = self.record class R: def first(self): return record def yield_per(self, n): if record is None: return iter(()) return iter((record,)) return R() def add(self, record, *a, **k): self.record = record def flush(self, *a, **k): pass def delete(self, *a, **k): self.record = None def expunge(self, *a, **k): pass def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return False class DummyMaiImage: def __init__(self, full_path=None, image_bytes=None): self.full_path = full_path self.image_bytes = image_bytes self.file_hash = "dummy-hash" self.image_format = "png" self.description = "" self.vlm_processed = False @classmethod def from_db_instance(cls, record): image = cls(full_path=getattr(record, "full_path", None)) image.file_hash = getattr(record, "image_hash", "dummy-hash") image.description = getattr(record, "description", "") image.vlm_processed = getattr(record, "vlm_processed", False) return image def to_db_instance(self): return types.SimpleNamespace( description=self.description, full_path=str(self.full_path) if self.full_path is not None else "", id=1, image_hash=self.file_hash, image_type="image", last_used_time=None, no_file_flag=False, query_count=0, register_time=None, vlm_processed=self.vlm_processed, ) async def calculate_hash_format(self): self.file_hash = "dummy-hash" return None class DummyLLMRequest: def __init__(self, *a, **k): pass async def generate_response_for_image(self, prompt, image_base64, image_format, temp): return ("dummy description", {}) class DummyLLMServiceClient: def __init__(self, *a, **k): pass async def generate_response_for_image(self, prompt, image_base64, image_format, options=None): return types.SimpleNamespace(response="dummy description") class DummySelect: def __init__(self, *a, **k): pass def filter_by(self, *a, **k): return self def limit(self, n): return self class DetachedRecord: def __init__(self, description="cached description", vlm_processed=True): self._detached = False self._description = description self._vlm_processed = vlm_processed @property def description(self): if not self._detached: raise RuntimeError("attribute refresh operation cannot proceed") return self._description @property def vlm_processed(self): if not self._detached: raise RuntimeError("attribute refresh operation cannot proceed") return self._vlm_processed class DetachedRecordSession(DummySession): def __init__(self, record): self.record = record def exec(self, *a, **k): record = self.record class R: def first(self): return record return R() def expunge(self, record): record._detached = True @pytest.fixture(autouse=True) def patch_external_dependencies(monkeypatch): # Provide dummy implementations as modules so that importing image_manager is safe # Patch LLMRequest llm_mod = types.SimpleNamespace(LLMRequest=DummyLLMRequest) monkeypatch.setitem(sys.modules, "src.llm_models.utils_model", llm_mod) llm_service_mod = types.SimpleNamespace(LLMServiceClient=DummyLLMServiceClient) monkeypatch.setitem(sys.modules, "src.services.llm_service", llm_service_mod) # Patch logger logger_mod = types.SimpleNamespace(get_logger=lambda name: DummyLogger()) monkeypatch.setitem(sys.modules, "src.common.logger", logger_mod) # Patch DB session provider shared_session = DummySession() db_mod = types.SimpleNamespace(get_db_session=lambda: shared_session) monkeypatch.setitem(sys.modules, "src.common.database.database", db_mod) # Patch database model types db_model_mod = types.SimpleNamespace(Images=types.SimpleNamespace, ImageType=types.SimpleNamespace(IMAGE="image")) monkeypatch.setitem(sys.modules, "src.common.database.database_model", db_model_mod) # Patch MaiImage data model data_model_mod = types.SimpleNamespace(MaiImage=DummyMaiImage) monkeypatch.setitem(sys.modules, "src.common.data_models.image_data_model", data_model_mod) # Patch SQLModel select function sql_mod = types.SimpleNamespace(select=lambda *a, **k: DummySelect()) monkeypatch.setitem(sys.modules, "sqlmodel", sql_mod) # Patch prompt manager used to build image description prompt. class _PromptManager: def get_prompt(self, _name): return types.SimpleNamespace() async def render_prompt(self, _prompt): return "test-style" prompt_manager_mod = types.SimpleNamespace(prompt_manager=_PromptManager()) monkeypatch.setitem(sys.modules, "src.prompt.prompt_manager", prompt_manager_mod) llm_options_mod = types.SimpleNamespace(LLMImageOptions=lambda **kwargs: types.SimpleNamespace(**kwargs)) monkeypatch.setitem(sys.modules, "src.common.data_models.llm_service_data_models", llm_options_mod) # If module already imported, reload it to apply patches mod_name = "src.chat.image_system.image_manager" if mod_name in sys.modules: importlib.reload(sys.modules[mod_name]) yield def _load_image_manager_module(tmp_path=None): repo_root = Path(__file__).parent.parent.parent file_path = repo_root / "src" / "chat" / "image_system" / "image_manager.py" spec = importlib.util.spec_from_file_location("image_manager_test_loaded", str(file_path)) mod = importlib.util.module_from_spec(spec) sys.modules[spec.name] = mod spec.loader.exec_module(mod) # Redirect IMAGE_DIR to pytest's tmp_path when provided try: if tmp_path is not None: tmpdir = Path(tmp_path) tmpdir.mkdir(parents=True, exist_ok=True) mod.IMAGE_DIR = tmpdir except Exception: pass return mod @pytest.mark.asyncio async def test_get_image_description_generates(tmp_path): image_manager = _load_image_manager_module(tmp_path) mgr = image_manager.ImageManager() desc = await mgr.get_image_description(image_bytes=b"abc") assert desc == "dummy description" def test_get_image_from_db_none(tmp_path): image_manager = _load_image_manager_module(tmp_path) mgr = image_manager.ImageManager() assert mgr.get_image_from_db("nohash") is None def test_register_image_to_db(tmp_path): image_manager = _load_image_manager_module(tmp_path) mgr = image_manager.ImageManager() p = tmp_path / "img.png" p.write_bytes(b"data") img = DummyMaiImage(full_path=p, image_bytes=b"data") assert mgr.register_image_to_db(img) is True def test_update_image_description_not_found(tmp_path): image_manager = _load_image_manager_module(tmp_path) mgr = image_manager.ImageManager() img = DummyMaiImage() img.file_hash = "nohash" img.description = "desc" assert mgr.update_image_description(img) is False def test_delete_image_not_found(tmp_path): image_manager = _load_image_manager_module(tmp_path) mgr = image_manager.ImageManager() img = DummyMaiImage() img.file_hash = "nohash" img.full_path = tmp_path = None assert mgr.delete_image(img) is False @pytest.mark.asyncio async def test_save_image_and_process_and_cleanup(tmp_path): image_manager = _load_image_manager_module(tmp_path) mgr = image_manager.ImageManager() # call save_image_and_process image = await mgr.save_image_and_process(b"binarydata") assert getattr(image, "description", None) == "dummy description" # cleanup should run without error mgr.cleanup_invalid_descriptions_in_db() @pytest.mark.asyncio async def test_get_image_description_returns_cached_description_after_session_closed(monkeypatch, tmp_path): image_manager = _load_image_manager_module(tmp_path) cached_record = DetachedRecord() monkeypatch.setattr(image_manager, "get_db_session", lambda: DetachedRecordSession(cached_record)) mgr = image_manager.ImageManager() desc = await mgr.get_image_description(image_hash="cached-hash", wait_for_build=False) assert desc == "cached description"