""" tests/test_stepan_v4_vision_guard.py Unit tests for Vision Consistency Guard (v4.0.1). Scope: vision_guard.py — без залежностей від crewai, httpx, memory-service. """ from __future__ import annotations import time import pytest import vision_guard as vg @pytest.fixture(autouse=True) def clear_lock(): """Очищуємо глобальний стор перед кожним тестом.""" vg._VISION_LOCK.clear() yield vg._VISION_LOCK.clear() # ─── _photo_key ─────────────────────────────────────────────────────────────── class TestPhotoKey: def test_prefers_file_unique_id(self): assert vg._photo_key("file_id_1", "unique_abc") == "unique_abc" def test_fallback_to_file_id(self): assert vg._photo_key("file_id_1", None) == "file_id_1" def test_fallback_empty_unique(self): assert vg._photo_key("file_id_1", "") == "file_id_1" def test_strips_whitespace(self): assert vg._photo_key("fid", " uid ") == "uid" # ─── extract_label_from_response ───────────────────────────────────────────── class TestExtractLabel: def test_crop_corn(self): label, conf = vg.extract_label_from_response( "На фото кукурудза на стадії V4. Листки виглядають здоровими." ) assert label == "кукурудза" assert conf == "high" def test_crop_wheat(self): label, conf = vg.extract_label_from_response("Це пшениця, стан задовільний.") assert label == "пшениця" def test_crop_sunflower_en(self): label, conf = vg.extract_label_from_response("The photo shows a sunflower field.") assert label == "sunflower" assert conf == "high" def test_diagnosis(self): label, conf = vg.extract_label_from_response( "Помітний хлороз міжжилкового типу — можливий дефіцит мангану або заліза." ) assert "хлороз" in label or "дефіцит" in label def test_low_confidence_flag(self): _, conf = vg.extract_label_from_response( "Можливо, це кукурудза, але важко сказати без деталей." ) assert conf == "low" def test_high_confidence_flag(self): _, conf = vg.extract_label_from_response("Це кукурудза.") assert conf == "high" def test_unknown_when_no_label(self): label, conf = vg.extract_label_from_response("Гарне фото поля.") assert label == "" assert conf == "unknown" def test_empty_string(self): label, conf = vg.extract_label_from_response("") assert label == "" assert conf == "unknown" # ─── set_vision_lock / get_vision_lock ─────────────────────────────────────── class TestVisionLock: def test_set_and_get(self): vg.set_vision_lock("agromatrix", "chat1", "fid_1", "кукурудза", "high", file_unique_id="uid_1") lock = vg.get_vision_lock("agromatrix", "chat1") assert lock["photo_key"] == "uid_1" assert lock["file_id"] == "fid_1" assert lock["label"] == "кукурудза" assert lock["confidence"] == "high" def test_photo_key_fallback_to_file_id(self): vg.set_vision_lock("agromatrix", "chat1", "fid_1", "пшениця", "high") lock = vg.get_vision_lock("agromatrix", "chat1") assert lock["photo_key"] == "fid_1" def test_different_chats_isolated(self): vg.set_vision_lock("agromatrix", "chat1", "f1", "пшениця", "high", file_unique_id="u1") vg.set_vision_lock("agromatrix", "chat2", "f2", "соняшник", "low", file_unique_id="u2") assert vg.get_vision_lock("agromatrix", "chat1")["label"] == "пшениця" assert vg.get_vision_lock("agromatrix", "chat2")["label"] == "соняшник" def test_empty_before_set(self): assert vg.get_vision_lock("agromatrix", "chat99") == {} def test_ttl_expiry(self): vg.set_vision_lock("agromatrix", "chatX", "fid", "ріпак", "high") key = "agromatrix:chatX" vg._VISION_LOCK[key]["ts"] = time.time() - vg.VISION_LOCK_TTL - 1 assert vg.get_vision_lock("agromatrix", "chatX") == {} def test_preserves_user_label_on_update(self): vg.set_vision_lock("agromatrix", "chat1", "fid_1", "кукурудза", "high", file_unique_id="uid_1") vg.set_user_label("agromatrix", "chat1", "Соняшник") # Оновлюємо lock — user_label має зберегтися vg.set_vision_lock("agromatrix", "chat1", "fid_2", "пшениця", "high", file_unique_id="uid_2") lock = vg.get_vision_lock("agromatrix", "chat1") assert lock["user_label"] == "соняшник" def test_clear_vision_lock(self): vg.set_vision_lock("agromatrix", "chat1", "fid", "кукурудза", "high") vg.clear_vision_lock("agromatrix", "chat1") assert vg.get_vision_lock("agromatrix", "chat1") == {} def test_clear_nonexistent_no_error(self): vg.clear_vision_lock("agromatrix", "no_such_chat") # не кидає # ─── set_user_label / detect_user_override ─────────────────────────────────── class TestUserOverride: # ── Позитивні кейси ────────────────────────────────────────────────────── def test_detect_plain(self): assert vg.detect_user_override("це соняшник") == "соняшник" def test_detect_with_punctuation(self): assert vg.detect_user_override("це пшениця!") == "пшениця" def test_detect_word_only(self): result = vg.detect_user_override("кукурудза") assert result == "кукурудза" def test_detect_bur_yan(self): result = vg.detect_user_override("це бур'ян") assert result != "" def test_detect_grunt(self): result = vg.detect_user_override("це ґрунт") assert result != "" def test_detect_shkidnyk(self): result = vg.detect_user_override("це шкідник") assert result != "" # ── Негація — заборона ─────────────────────────────────────────────────── def test_negation_ce_ne(self): assert vg.detect_user_override("це не соняшник") == "" def test_negation_to_ne(self): assert vg.detect_user_override("то не пшениця") == "" def test_negation_just_ne(self): assert vg.detect_user_override("не кукурудза") == "" # ── Не override ────────────────────────────────────────────────────────── def test_no_override_long_text(self): assert vg.detect_user_override("покажи скільки там добрив") == "" def test_no_override_question(self): assert vg.detect_user_override("що це за хвороба?") == "" def test_no_override_not_in_whitelist(self): assert vg.detect_user_override("це якийсь невідомий об'єкт") == "" # ── set_user_label ──────────────────────────────────────────────────────── def test_set_and_read_user_label(self): vg.set_vision_lock("agromatrix", "c1", "f1", "кукурудза", "high") vg.set_user_label("agromatrix", "c1", "Соняшник") lock = vg.get_vision_lock("agromatrix", "c1") assert lock["user_label"] == "соняшник" def test_set_user_label_no_lock(self): """set_user_label без попереднього lock — не має падати.""" vg.set_user_label("agromatrix", "newchat", "ячмінь") lock = vg.get_vision_lock("agromatrix", "newchat") assert lock.get("user_label") == "ячмінь" # ─── is_reeval_request ─────────────────────────────────────────────────────── class TestReeval: def test_reeval_explicit(self): assert vg.is_reeval_request("переоцінити це фото") assert vg.is_reeval_request("перевір ще раз") assert vg.is_reeval_request("не те, переглянь") def test_no_reeval(self): assert not vg.is_reeval_request("що з цим фото?") assert not vg.is_reeval_request("дякую") assert not vg.is_reeval_request("") # ─── should_skip_reanalysis ────────────────────────────────────────────────── class TestShouldSkip: def test_skip_same_file_unique_id(self): vg.set_vision_lock("agromatrix", "c1", "fid_1", "кукурудза", "high", file_unique_id="uid_same") assert vg.should_skip_reanalysis("agromatrix", "c1", "fid_1", "що там?", file_unique_id="uid_same") def test_skip_same_file_different_sizes(self): """Різні file_id але той самий file_unique_id (різні розміри) → skip.""" vg.set_vision_lock("agromatrix", "c1", "fid_small", "пшениця", "high", file_unique_id="uid_photo_1") # Надсилається той самий file_unique_id але з іншим file_id (великий розмір) assert vg.should_skip_reanalysis("agromatrix", "c1", "fid_large", "що там?", file_unique_id="uid_photo_1") def test_no_skip_different_unique_id(self): vg.set_vision_lock("agromatrix", "c1", "fid_1", "кукурудза", "high", file_unique_id="uid_1") assert not vg.should_skip_reanalysis("agromatrix", "c1", "fid_2", "що там?", file_unique_id="uid_2") def test_no_skip_different_file_id_no_unique(self): vg.set_vision_lock("agromatrix", "c1", "fid_old", "кукурудза", "high") assert not vg.should_skip_reanalysis("agromatrix", "c1", "fid_new", "що там?") def test_reeval_clears_lock(self): """Rule C: reeval_request → clear_lock → skip=False.""" vg.set_vision_lock("agromatrix", "c1", "fid_same", "кукурудза", "high", file_unique_id="uid_same") result = vg.should_skip_reanalysis("agromatrix", "c1", "fid_same", "перевір ще раз", file_unique_id="uid_same") assert result is False # Lock має бути очищено assert vg.get_vision_lock("agromatrix", "c1") == {} def test_no_skip_no_lock(self): assert not vg.should_skip_reanalysis("agromatrix", "c_empty", "fid_x", "") def test_no_skip_expired_lock(self): vg.set_vision_lock("agromatrix", "c1", "fid_same", "пшениця", "high", file_unique_id="uid_same") vg._VISION_LOCK["agromatrix:c1"]["ts"] = time.time() - vg.VISION_LOCK_TTL - 1 assert not vg.should_skip_reanalysis("agromatrix", "c1", "fid_same", "", file_unique_id="uid_same") # ─── build_low_confidence_clarifier ────────────────────────────────────────── class TestLowConfidenceClarifier: def test_appends_clarifier_when_low_conf(self): text = "Можливо, це кукурудза, але важко сказати без деталей." result, added = vg.build_low_confidence_clarifier(text) assert added is True assert len(result) > len(text) assert "?" in result def test_no_change_when_high_conf(self): text = "Це кукурудза на стадії V4." result, added = vg.build_low_confidence_clarifier(text) assert added is False assert result == text def test_no_duplicate_clarifier(self): """Якщо відповідь вже містить '?' наприкінці — не дублювати.""" text = "Можливо, це пшениця. Хочете уточнити?" result, added = vg.build_low_confidence_clarifier(text) assert added is False # вже є '?' def test_empty_input(self): result, added = vg.build_low_confidence_clarifier("") assert result == "" assert added is False def test_returns_tuple(self): out = vg.build_low_confidence_clarifier("Це пшениця.") assert isinstance(out, tuple) and len(out) == 2 # ─── build_locked_reply ─────────────────────────────────────────────────────── class TestLockedReply: def test_high_conf(self): lock = {"file_id": "f1", "photo_key": "u1", "label": "кукурудза", "confidence": "high", "user_label": ""} reply = vg.build_locked_reply(lock, "що там?") assert "кукурудза" in reply.lower() or "Кукурудза" in reply assert "?" in reply def test_user_label_takes_priority(self): lock = {"file_id": "f1", "photo_key": "u1", "label": "пшениця", "confidence": "high", "user_label": "соняшник"} reply = vg.build_locked_reply(lock, "") assert "соняшник" in reply.lower() or "Соняшник" in reply assert "підтвердив" in reply def test_low_conf(self): lock = {"file_id": "f1", "photo_key": "u1", "label": "ріпак", "confidence": "low", "user_label": ""} reply = vg.build_locked_reply(lock, "") assert "ріпак" in reply.lower() or "Ріпак" in reply assert "впевненість" in reply.lower() or "переоцінити" in reply.lower() def test_empty_label(self): lock = {"file_id": "f1", "photo_key": "u1", "label": "", "confidence": "unknown", "user_label": ""} reply = vg.build_locked_reply(lock, "") assert len(reply) > 0 # fallback рядок # ─── TTL та cleanup ─────────────────────────────────────────────────────────── class TestTTL: def test_cleanup_removes_expired(self): vg.set_vision_lock("agromatrix", "c_exp", "f1", "ячмінь", "high", file_unique_id="u_exp") vg._VISION_LOCK["agromatrix:c_exp"]["ts"] = time.time() - vg.VISION_LOCK_TTL - 5 vg.set_vision_lock("agromatrix", "c_fresh", "f2", "горох", "high", file_unique_id="u_fresh") vg._cleanup() assert "agromatrix:c_exp" not in vg._VISION_LOCK assert "agromatrix:c_fresh" in vg._VISION_LOCK def test_lock_ttl_constant_correct(self): assert vg.VISION_LOCK_TTL == 1800.0 # ─── Smoke-test сценарій: повний цикл ──────────────────────────────────────── class TestSmokeCycle: def test_full_cycle(self): """ Сценарій: 1. Надіслали фото → lock_set (кукурудза, high) 2. Повторно той самий file_unique_id → skip 3. Юзер пише "це соняшник" → user_label 4. Repeat → locked_reply з user_label 5. "перевір ще раз" → clear_lock → no skip """ # 1. Lock після аналізу vg.set_vision_lock("agromatrix", "chat_smoke", "fid_a", "кукурудза", "high", file_unique_id="uid_photo") lock = vg.get_vision_lock("agromatrix", "chat_smoke") assert lock["label"] == "кукурудза" # 2. Той самий file_unique_id, інший file_id (розмір) → skip skip = vg.should_skip_reanalysis("agromatrix", "chat_smoke", "fid_b", "що там?", file_unique_id="uid_photo") assert skip is True # 3. User override "це соняшник" override = vg.detect_user_override("це соняшник") assert override == "соняшник" vg.set_user_label("agromatrix", "chat_smoke", override) # 4. Locked reply — має повернути соняшник (user_label) lock2 = vg.get_vision_lock("agromatrix", "chat_smoke") reply = vg.build_locked_reply(lock2, "знову надіслав фото") assert "соняшник" in reply.lower() or "Соняшник" in reply # 5. Reeval → clear_lock → no skip skip2 = vg.should_skip_reanalysis("agromatrix", "chat_smoke", "fid_b", "перевір ще раз", file_unique_id="uid_photo") assert skip2 is False assert vg.get_vision_lock("agromatrix", "chat_smoke") == {}