""" tests/test_stepan_v4_farm_state.py v4 Farm State Layer: - detect_farm_state_updates (crop, stage, issue, risk) - update_farm_state (session merge, dedup, TTL) - build_farm_state_prefix (format, empty cases, TTL) - Isolation: prefix NOT added in doc/web mode - State persists within session """ import time import pytest from crews.agromatrix_crew.farm_state import ( detect_farm_state_updates, update_farm_state, build_farm_state_prefix, FARM_STATE_TTL, ) # ─── 1. detect_farm_state_updates ──────────────────────────────────────────── class TestDetectFarmStateUpdates: def test_crop_kukurudza(self): u = detect_farm_state_updates("По кукурудзі що робити далі?") assert u.get("current_crop") == "кукурудза" def test_crop_inflection_kukurudzu(self): u = detect_farm_state_updates("Переглянь стан кукурудзу на полі") assert u.get("current_crop") == "кукурудза" def test_crop_wheat(self): u = detect_farm_state_updates("Пшениця виглядає кволою") assert u.get("current_crop") == "пшениця" def test_crop_sunflower(self): u = detect_farm_state_updates("соняшник V6 посуха") assert u.get("current_crop") == "соняшник" def test_crop_rapeseed(self): u = detect_farm_state_updates("ріпак — йде цвітіння") assert u.get("current_crop") == "ріпак" def test_stage_v6(self): u = detect_farm_state_updates("Стадія V6, є жовтизна") assert u.get("growth_stage") == "V6" def test_stage_kushennya(self): u = detect_farm_state_updates("пшениця, фаза кущення") assert "кущення" in u.get("growth_stage", "").lower() def test_stage_flowering(self): u = detect_farm_state_updates("ріпак іде на цвітіння") assert "цвітіння" in u.get("growth_stage", "").lower() def test_issue_zhovtyzna(self): u = detect_farm_state_updates("є жовтизна на листі") assert "жовтизна" in u.get("recent_issue", "").lower() def test_issue_deficit(self): u = detect_farm_state_updates("дефіцит азоту у посівах") assert "дефіцит" in u.get("recent_issue", "").lower() def test_issue_shkidnyk(self): u = detect_farm_state_updates("з'явились шкідники на кукурудзі") assert "шкідник" in u.get("recent_issue", "").lower() def test_risk_posuxa(self): u = detect_farm_state_updates("на полі посуха вже 2 тижні") assert "посуха" in u.get("risk_flags", []) def test_risk_zamorozok(self): u = detect_farm_state_updates("очікується заморозок вночі") assert any("заморозок" in r for r in u.get("risk_flags", [])) def test_multiple_fields(self): u = detect_farm_state_updates("кукурудза V6, дефіцит азоту, посуха") assert u.get("current_crop") == "кукурудза" assert "V6" in u.get("growth_stage", "") assert "дефіцит" in u.get("recent_issue", "").lower() assert any("посуха" in r for r in u.get("risk_flags", [])) def test_empty_text(self): u = detect_farm_state_updates("") assert u == {} def test_no_match(self): u = detect_farm_state_updates("привіт, як справи сьогодні?") assert u == {} def test_fail_safe_garbage(self): u = detect_farm_state_updates(None) # type: ignore assert u == {} # ─── 2. update_farm_state ───────────────────────────────────────────────────── class TestUpdateFarmState: def test_creates_farm_state(self): session = {} update_farm_state(session, {"current_crop": "кукурудза"}) assert session["farm_state"]["current_crop"] == "кукурудза" def test_sets_last_update_ts(self): now = time.time() session = {} update_farm_state(session, {"current_crop": "соняшник"}, now_ts=now) assert abs(session["farm_state"]["last_update_ts"] - now) < 1 def test_updates_existing(self): session = {"farm_state": {"current_crop": "пшениця", "last_update_ts": 0.0}} update_farm_state(session, {"current_crop": "кукурудза"}) assert session["farm_state"]["current_crop"] == "кукурудза" def test_merges_risk_flags(self): session = {"farm_state": {"risk_flags": ["посуха"], "last_update_ts": 0.0}} update_farm_state(session, {"risk_flags": ["заморозок"]}) flags = session["farm_state"]["risk_flags"] assert "посуха" in flags assert "заморозок" in flags def test_dedup_risk_flags(self): session = {"farm_state": {"risk_flags": ["посуха", "спека"], "last_update_ts": 0.0}} update_farm_state(session, {"risk_flags": ["посуха"]}) flags = session["farm_state"]["risk_flags"] assert flags.count("посуха") == 1 def test_risk_flags_max_5(self): session = {"farm_state": {"risk_flags": ["r1", "r2", "r3", "r4"], "last_update_ts": 0.0}} update_farm_state(session, {"risk_flags": ["r5", "r6"]}) assert len(session["farm_state"]["risk_flags"]) <= 5 def test_empty_updates_noop(self): session = {"farm_state": {"current_crop": "ріпак", "last_update_ts": 0.0}} update_farm_state(session, {}) assert session["farm_state"]["current_crop"] == "ріпак" def test_fail_safe_exception(self): # session = None → fail-safe, no exception update_farm_state(None, {"current_crop": "соя"}) # type: ignore # ─── 3. build_farm_state_prefix ────────────────────────────────────────────── class TestBuildFarmStatePrefix: def _session_with_state(self, now: float, **kwargs) -> dict: fs = {"last_update_ts": now} fs.update(kwargs) return {"farm_state": fs} def test_empty_if_no_crop(self): now = time.time() s = self._session_with_state(now, growth_stage="V6") assert build_farm_state_prefix(s) == "" def test_basic_prefix(self): now = time.time() s = self._session_with_state(now, current_crop="кукурудза") p = build_farm_state_prefix(s) assert "кукурудза" in p assert "[Контекст господарства]" in p def test_includes_stage(self): now = time.time() s = self._session_with_state(now, current_crop="кукурудза", growth_stage="V6") p = build_farm_state_prefix(s) assert "V6" in p assert "Стадія" in p def test_includes_issue(self): now = time.time() s = self._session_with_state(now, current_crop="кукурудза", recent_issue="жовтизна") p = build_farm_state_prefix(s) assert "жовтизна" in p assert "Проблема" in p def test_includes_risks(self): now = time.time() s = self._session_with_state(now, current_crop="соняшник", risk_flags=["посуха", "спека"]) p = build_farm_state_prefix(s) assert "посуха" in p assert "Ризики" in p def test_max_lines(self): now = time.time() s = self._session_with_state( now, current_crop="кукурудза", growth_stage="V6", recent_issue="жовтизна", risk_flags=["посуха", "заморозок"], ) p = build_farm_state_prefix(s) assert len(p.splitlines()) <= 5 def test_empty_if_ttl_expired(self): old_ts = time.time() - FARM_STATE_TTL - 10 s = self._session_with_state(old_ts, current_crop="кукурудза") assert build_farm_state_prefix(s) == "" def test_empty_if_no_farm_state(self): assert build_farm_state_prefix({}) == "" def test_fail_safe(self): assert build_farm_state_prefix(None) == "" # type: ignore # ─── 4. State persists within session ──────────────────────────────────────── class TestFarmStatePersistence: def test_crop_persists_across_messages(self): session = {} # Перше повідомлення — встановлює crop u1 = detect_farm_state_updates("По кукурудзі є дефіцит азоту") update_farm_state(session, u1) # Друге повідомлення — без crop, але є farm_state u2 = detect_farm_state_updates("Що робити?") update_farm_state(session, u2) # Стан зберігся assert session["farm_state"]["current_crop"] == "кукурудза" def test_issue_updates_on_new_message(self): session = {} update_farm_state(session, {"current_crop": "пшениця", "recent_issue": "жовтизна"}) update_farm_state(session, {"recent_issue": "іржа"}) assert session["farm_state"]["recent_issue"] == "іржа" def test_stage_updates(self): session = {} update_farm_state(session, {"current_crop": "кукурудза", "growth_stage": "V4"}) update_farm_state(session, {"growth_stage": "V6"}) assert session["farm_state"]["growth_stage"] == "V6" # ─── 5. Ізоляція: prefix NOT в doc / web mode ───────────────────────────────── def _would_inject(context_mode: str, domain: str, session: dict) -> bool: """Симуляція умови ін'єкції з run.py.""" if context_mode == "doc" or domain == "web": return False return bool(build_farm_state_prefix(session)) class TestFarmStatePrefixIsolation: def _fresh_session(self) -> dict: now = time.time() session = {} update_farm_state(session, {"current_crop": "кукурудза"}, now_ts=now) return session def test_not_injected_in_doc_mode(self): s = self._fresh_session() assert not _would_inject("doc", "doc", s) def test_not_injected_in_web_domain(self): s = self._fresh_session() assert not _would_inject("general", "web", s) def test_injected_in_general_mode(self): s = self._fresh_session() assert _would_inject("general", "general", s) def test_injected_in_vision_domain(self): s = self._fresh_session() assert _would_inject("general", "vision", s) def test_not_injected_if_no_crop(self): s = {"farm_state": {"growth_stage": "V6", "last_update_ts": time.time()}} assert not _would_inject("general", "general", s) def test_not_injected_if_ttl_expired(self): old_ts = time.time() - FARM_STATE_TTL - 60 s = {"farm_state": {"current_crop": "кукурудза", "last_update_ts": old_ts}} assert not _would_inject("general", "general", s) # ─── 6. FARM_STATE_TTL constant ────────────────────────────────────────────── class TestConstants: def test_farm_state_ttl_30min(self): assert FARM_STATE_TTL == 1800.0