""" tests/test_stepan_v42_vision_bridge.py Unit tests for v4.2 Vision → Agronomy Bridge. Тестуємо ізольовано: без crewai, без httpx, без memory-service. Перевіряємо логіку безпосередньо з vision_guard + session_context. """ from __future__ import annotations import time import sys import os import pytest # ── Налаштовуємо шляхи для локального запуску ──────────────────────────────── _ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) _GATEWAY = os.path.join(_ROOT, "gateway-bot") _CREWS = os.path.join(_ROOT, "crews") if _GATEWAY not in sys.path: sys.path.insert(0, _GATEWAY) if _CREWS not in sys.path: sys.path.insert(0, _CREWS) import vision_guard as vg # ── Fixtures ────────────────────────────────────────────────────────────────── @pytest.fixture(autouse=True) def clear_vg_lock(): vg._VISION_LOCK.clear() yield vg._VISION_LOCK.clear() def _make_session() -> dict: """Мінімальна сесія для тестів (без load_session).""" return { "doc_focus": False, "doc_focus_ts": 0.0, "doc_focus_cooldown_until": 0.0, "active_doc_id": None, "doc_facts": {}, "fact_claims": [], "last_photo_ts": 0.0, "farm_state": {}, "vision_last_label": "", "updated_at": time.time(), } # ── Helper: симулює логіку v4.2 з run.py ───────────────────────────────────── def _run_vision_bridge( session: dict, agent_id: str, chat_id: str, text: str = "", ) -> dict: """ Відтворює блок v4.2 з run.py (без crewai/LLM). Повертає оновлену session. """ # Читаємо vision lock (аналог _vb_get_vision_lock) try: lock = vg.get_vision_lock(agent_id, chat_id) if lock: label = (lock.get("user_label") or lock.get("label") or "").strip() if label: session["vision_last_label"] = label except Exception: pass # User text override if text: try: text_label = vg.detect_user_override(text) if text_label: session["vision_last_label"] = text_label except Exception: pass return session def _should_inject_prefix(session: dict, context_mode: str, domain: str) -> bool: """Відтворює умову для vision bridge prefix injection.""" if context_mode == "doc": return False if domain == "web": return False label = (session.get("vision_last_label") or "").strip() farm_crop = str((session.get("farm_state") or {}).get("current_crop", "")).strip() return bool(label and label != farm_crop) # ─── Тест 1: vision label з'являється у session після lock ─────────────────── class TestVisionLabelInSession: def test_label_loaded_from_lock(self): """Якщо vision lock є — vision_last_label заповнюється.""" vg.set_vision_lock("agromatrix", "chat1", "fid", "соняшник", "high", file_unique_id="uid1") session = _make_session() session = _run_vision_bridge(session, "agromatrix", "chat1") assert session["vision_last_label"] == "соняшник" def test_user_label_preferred_over_model_label(self): """user_label має пріоритет над model label.""" vg.set_vision_lock("agromatrix", "chat1", "fid", "пшениця", "high", file_unique_id="uid1") vg.set_user_label("agromatrix", "chat1", "Соняшник") session = _make_session() session = _run_vision_bridge(session, "agromatrix", "chat1") assert session["vision_last_label"] == "соняшник" def test_no_label_if_no_lock(self): """Без lock — vision_last_label порожній.""" session = _make_session() session = _run_vision_bridge(session, "agromatrix", "chat_new") assert session["vision_last_label"] == "" def test_expired_lock_no_label(self): """Протухлий lock — не вставляємо label.""" vg.set_vision_lock("agromatrix", "chatX", "fid", "кукурудза", "high") vg._VISION_LOCK["agromatrix:chatX"]["ts"] = time.time() - vg.VISION_LOCK_TTL - 1 session = _make_session() session = _run_vision_bridge(session, "agromatrix", "chatX") assert session["vision_last_label"] == "" # ─── Тест 2: prefix injection тільки не в doc/web режимі ───────────────────── class TestPrefixInjection: def test_injected_in_general_mode(self): vg.set_vision_lock("agromatrix", "chat1", "fid", "соняшник", "high") session = _make_session() session = _run_vision_bridge(session, "agromatrix", "chat1") assert _should_inject_prefix(session, "general", "general") is True def test_not_injected_in_doc_mode(self): """Rule: doc mode → NЕ вставляємо vision prefix.""" vg.set_vision_lock("agromatrix", "chat1", "fid", "кукурудза", "high") session = _make_session() session = _run_vision_bridge(session, "agromatrix", "chat1") assert _should_inject_prefix(session, "doc", "general") is False def test_not_injected_in_web_mode(self): """Rule: web mode → НЕ вставляємо vision prefix.""" vg.set_vision_lock("agromatrix", "chat1", "fid", "пшениця", "high") session = _make_session() session = _run_vision_bridge(session, "agromatrix", "chat1") assert _should_inject_prefix(session, "general", "web") is False def test_not_injected_if_empty_label(self): """Без label — не вставляємо.""" session = _make_session() assert _should_inject_prefix(session, "general", "general") is False def test_not_injected_if_same_as_farm_crop(self): """Якщо farm_state.current_crop === vision_last_label — не дублюємо.""" vg.set_vision_lock("agromatrix", "chat1", "fid", "кукурудза", "high") session = _make_session() session = _run_vision_bridge(session, "agromatrix", "chat1") session["farm_state"] = {"current_crop": "кукурудза"} assert _should_inject_prefix(session, "general", "general") is False # ─── Тест 3: user text override ────────────────────────────────────────────── class TestTextOverride: def test_text_override_changes_label(self): """Юзер явно пише "це соняшник" → перезаписує label.""" vg.set_vision_lock("agromatrix", "chat1", "fid", "пшениця", "high") session = _make_session() session["vision_last_label"] = "пшениця" # Симулюємо нову відправку з override text session = _run_vision_bridge(session, "agromatrix", "chat1", text="це соняшник") assert session["vision_last_label"] == "соняшник" def test_negation_does_not_override(self): """Негація "це не соняшник" → не перезаписує.""" vg.set_vision_lock("agromatrix", "chat1", "fid", "пшениця", "high") session = _make_session() session["vision_last_label"] = "пшениця" session = _run_vision_bridge(session, "agromatrix", "chat1", text="це не соняшник") assert session["vision_last_label"] == "пшениця" def test_irrelevant_text_no_change(self): """Звичайний текст → label не змінюється.""" vg.set_vision_lock("agromatrix", "chat1", "fid", "ріпак", "high") session = _make_session() session["vision_last_label"] = "ріпак" session = _run_vision_bridge(session, "agromatrix", "chat1", text="чи варто внести азот зараз?") assert session["vision_last_label"] == "ріпак" def test_text_override_sets_label_without_lock(self): """User пише "соняшник" навіть без попереднього lock.""" session = _make_session() session = _run_vision_bridge(session, "agromatrix", "chat_no_lock", text="це кукурудза") assert session["vision_last_label"] == "кукурудза" # ─── Тест 4: TTL — label зникає після 30 хвилин ────────────────────────────── class TestTTL: def test_label_absent_after_ttl(self): """Після TTL lock→ порожній → label не завантажується.""" vg.set_vision_lock("agromatrix", "chatTTL", "fid", "ячмінь", "high") # Симулюємо протухання vg._VISION_LOCK["agromatrix:chatTTL"]["ts"] = time.time() - vg.VISION_LOCK_TTL - 1 session = _make_session() session = _run_vision_bridge(session, "agromatrix", "chatTTL") assert session["vision_last_label"] == "" def test_label_present_within_ttl(self): """Якщо lock свіжий — label є.""" vg.set_vision_lock("agromatrix", "chatTTL2", "fid", "горох", "high") session = _make_session() session = _run_vision_bridge(session, "agromatrix", "chatTTL2") assert session["vision_last_label"] == "горох" # ─── Тест 5: ізоляція між чатами ───────────────────────────────────────────── class TestChatIsolation: def test_different_chats_isolated(self): """Два різні чати — окремі label.""" vg.set_vision_lock("agromatrix", "chatA", "f1", "соняшник", "high") vg.set_vision_lock("agromatrix", "chatB", "f2", "пшениця", "high") sA = _make_session() sA = _run_vision_bridge(sA, "agromatrix", "chatA") sB = _make_session() sB = _run_vision_bridge(sB, "agromatrix", "chatB") assert sA["vision_last_label"] == "соняшник" assert sB["vision_last_label"] == "пшениця" def test_forward_different_chat_no_bleed(self): """Forward фото в інший чат — чужий lock не потрапляє.""" vg.set_vision_lock("agromatrix", "chatSource", "f1", "ріпак", "high", file_unique_id="uid_shared") # В іншому чаті немає lock навіть з тим самим photo sB = _make_session() sB = _run_vision_bridge(sB, "agromatrix", "chatDest") assert sB["vision_last_label"] == "" # ─── Тест 6: Smoke cycle ───────────────────────────────────────────────────── class TestSmokeCycle: def test_full_vision_agronomy_flow(self): """ 1. Фото → lock_set (соняшник) 2. Степан отримує label у session 3. Питання без фото → prefix інжектується 4. Юзер каже "тепер це кукурудза" → label змінюється 5. TTL минає → label пропадає """ # 1. Vision response saved vg.set_vision_lock("agromatrix", "chat_cycle", "fid", "соняшник", "high", file_unique_id="uid_cycle") # 2. run.py bridge session = _make_session() session = _run_vision_bridge(session, "agromatrix", "chat_cycle") assert session["vision_last_label"] == "соняшник" # 3. Inject в general mode assert _should_inject_prefix(session, "general", "general") is True assert _should_inject_prefix(session, "doc", "general") is False # 4. Text override session = _run_vision_bridge(session, "agromatrix", "chat_cycle", text="тепер це кукурудза") assert session["vision_last_label"] == "кукурудза" # 5. TTL vg._VISION_LOCK["agromatrix:chat_cycle"]["ts"] = ( time.time() - vg.VISION_LOCK_TTL - 1 ) session2 = _make_session() session2 = _run_vision_bridge(session2, "agromatrix", "chat_cycle") assert session2["vision_last_label"] == ""