Files
microdao-daarion/tests/test_stepan_v46_farm_state.py
Apple 129e4ea1fc feat(platform): add new services, tools, tests and crews modules
New router intelligence modules (26 files): alert_ingest/store, audit_store,
architecture_pressure, backlog_generator/store, cost_analyzer, data_governance,
dependency_scanner, drift_analyzer, incident_* (5 files), llm_enrichment,
platform_priority_digest, provider_budget, release_check_runner, risk_* (6 files),
signature_state_store, sofiia_auto_router, tool_governance

New services:
- sofiia-console: Dockerfile, adapters/, monitor/nodes/ops/voice modules, launchd, react static
- memory-service: integration_endpoints, integrations, voice_endpoints, static UI
- aurora-service: full app suite (analysis, job_store, orchestrator, reporting, schemas, subagents)
- sofiia-supervisor: new supervisor service
- aistalk-bridge-lite: Telegram bridge lite
- calendar-service: CalDAV calendar service with reminders
- mlx-stt-service / mlx-tts-service: Apple Silicon speech services
- binance-bot-monitor: market monitor service
- node-worker: STT/TTS memory providers

New tools (9): agent_email, browser_tool, contract_tool, observability_tool,
oncall_tool, pr_reviewer_tool, repo_tool, safe_code_executor, secure_vault

New crews: agromatrix_crew (10 modules: depth_classifier, doc_facts, doc_focus,
farm_state, light_reply, llm_factory, memory_manager, proactivity, reflection_engine,
session_context, style_adapter, telemetry)

Tests: 85+ test files for all new modules
Made-with: Cursor
2026-03-03 07:14:14 -08:00

407 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
tests/test_stepan_v46_farm_state.py
Unit tests for v4.6 /farm state operator command.
Scope:
- handle_farm_command (routing + unknown subcommand)
- _handle_farm_state (full flow: ping → assets → snapshot → save)
- _parse_asset_lines / _label_from_asset_line / _build_snapshot_text
- _save_farm_state_snapshot (fail-closed, no leaking)
"""
from __future__ import annotations
import json
import os
import sys
import types
import pytest
from unittest.mock import patch, MagicMock
# ── Paths ──────────────────────────────────────────────────────────────────────
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_PKG = os.path.join(_ROOT, "packages", "agromatrix-tools")
_CREWS = os.path.join(_ROOT, "crews")
for _p in (_PKG, _CREWS):
if _p not in sys.path:
sys.path.insert(0, _p)
# ── Mocks for external deps ────────────────────────────────────────────────────
def _stub(name: str) -> types.ModuleType:
m = types.ModuleType(name)
sys.modules[name] = m
return m
for _mod in ("nats", "nats.aio", "nats.aio.client", "crewai", "crewai.tools"):
if _mod not in sys.modules:
_stub(_mod)
sys.modules["crewai.tools"].tool = lambda name: (lambda f: f) # type: ignore
sys.modules["nats.aio.client"].Client = MagicMock # type: ignore
_audit_stub = types.ModuleType("agromatrix_tools.audit")
_audit_stub.audit_tool_call = lambda *a, **kw: None # type: ignore
sys.modules["agromatrix_tools.audit"] = _audit_stub
# ── Import targets ─────────────────────────────────────────────────────────────
import importlib as _il
if "agromatrix_tools.tool_farmos_read" in sys.modules:
_il.reload(sys.modules["agromatrix_tools.tool_farmos_read"])
if "crews.agromatrix_crew.operator_commands" in sys.modules:
_il.reload(sys.modules["crews.agromatrix_crew.operator_commands"])
from crews.agromatrix_crew.operator_commands import ( # noqa: E402
handle_farm_command,
_parse_asset_lines,
_label_from_asset_line,
_build_snapshot_text,
_save_farm_state_snapshot,
OPERATOR_COMMANDS,
parse_operator_command,
)
# ── Fixtures ───────────────────────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def clear_farmos_env(monkeypatch):
for k in ("FARMOS_BASE_URL", "FARMOS_TOKEN", "FARMOS_USER", "FARMOS_PASS",
"FARMOS_PASSWORD", "AGX_MEMORY_SERVICE_URL", "MEMORY_SERVICE_URL"):
monkeypatch.delenv(k, raising=False)
yield
def _sample_asset_text(labels: list[str]) -> str:
"""Генерує рядки у форматі farmos_search_assets_impl."""
lines = [f"- {lbl} | asset--land | id=abc12345" for lbl in labels]
return "\n".join(lines)
# ─── OPERATOR_COMMANDS registry ───────────────────────────────────────────────
class TestFarmCommandRegistry:
def test_farm_in_operator_commands(self):
assert "farm" in OPERATOR_COMMANDS
def test_parse_farm_state(self):
parsed = parse_operator_command("/farm state")
assert parsed is not None
assert parsed["cmd"] == "farm"
assert "state" in parsed["args"]
def test_parse_farm_no_args(self):
parsed = parse_operator_command("/farm")
assert parsed is not None
assert parsed["cmd"] == "farm"
assert parsed["args"] == []
# ─── handle_farm_command ──────────────────────────────────────────────────────
class TestHandleFarmCommand:
def test_no_args_returns_help(self):
result = handle_farm_command([])
assert "підтримується" in result["summary"].lower()
assert "/farm state" in result["summary"]
def test_unknown_sub_returns_help(self):
result = handle_farm_command(["foo"])
assert "підтримується" in result["summary"].lower()
def test_returns_dict_with_required_keys(self):
result = handle_farm_command([])
for k in ("status", "summary", "artifacts", "tool_calls", "next_actions"):
assert k in result
def test_state_no_farmos_env(self):
"""Без FARMOS_BASE_URL → повертає config message, не падає."""
result = handle_farm_command(["state"], chat_id="test_chat")
assert "FarmOS" in result["summary"]
assert "FARMOS_BASE_URL" in result["summary"] or "налаштований" in result["summary"]
def test_state_ping_unreachable(self, monkeypatch):
"""Якщо ping повертає 'недоступний' → command зупиняється."""
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
monkeypatch.setenv("FARMOS_TOKEN", "tok")
with patch("agromatrix_tools.tool_farmos_read._farmos_ping_impl",
return_value="FarmOS недоступний: timeout (3s)."):
result = handle_farm_command(["state"], chat_id="chat1")
assert "недоступний" in result["summary"]
assert "timeout" in result["summary"]
def test_state_ping_ok_no_assets(self, monkeypatch):
"""Ping OK, але активів немає → повертає 'немає даних'."""
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
monkeypatch.setenv("FARMOS_TOKEN", "tok")
with patch("agromatrix_tools.tool_farmos_read._farmos_ping_impl",
return_value="FarmOS доступний."):
with patch("agromatrix_tools.tool_farmos_read._farmos_search_assets_impl",
return_value="FarmOS: нічого не знайдено."):
result = handle_farm_command(["state"], chat_id="chat1")
assert "немає даних" in result["summary"] or "FarmOS" in result["summary"]
def test_state_ping_ok_with_assets(self, monkeypatch):
"""Ping OK, активи знайдено → snapshot contains field count."""
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
monkeypatch.setenv("FARMOS_TOKEN", "tok")
def _mock_search(asset_type="asset_land", name_contains="", limit=10):
if asset_type == "asset_land":
return _sample_asset_text(["Поле Північ", "Поле Південь"])
if asset_type == "asset_plant":
return _sample_asset_text(["Пшениця"])
return "FarmOS: нічого не знайдено."
with patch("agromatrix_tools.tool_farmos_read._farmos_ping_impl",
return_value="FarmOS доступний."):
with patch("agromatrix_tools.tool_farmos_read._farmos_search_assets_impl",
side_effect=_mock_search):
with patch("crews.agromatrix_crew.operator_commands._save_farm_state_snapshot",
return_value=True):
result = handle_farm_command(["state"], chat_id="chat1")
assert "Farm state" in result["summary"]
assert "Поля" in result["summary"]
assert "2" in result["summary"] # 2 fields found
def test_state_snapshot_save_failure_appends_suffix(self, monkeypatch):
"""Якщо memory save fails → suffix додається до відповіді."""
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
monkeypatch.setenv("FARMOS_TOKEN", "tok")
with patch("agromatrix_tools.tool_farmos_read._farmos_ping_impl",
return_value="FarmOS доступний."):
with patch("agromatrix_tools.tool_farmos_read._farmos_search_assets_impl",
return_value=_sample_asset_text(["Поле 1"])):
with patch("crews.agromatrix_crew.operator_commands._save_farm_state_snapshot",
return_value=False):
result = handle_farm_command(["state"], chat_id="chat1")
assert "пам'ять" in result["summary"].lower() or "зберег" in result["summary"].lower()
def test_state_no_chat_id_still_returns_snapshot(self, monkeypatch):
"""Без chat_id snapshot формується, але не зберігається."""
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
monkeypatch.setenv("FARMOS_TOKEN", "tok")
with patch("agromatrix_tools.tool_farmos_read._farmos_ping_impl",
return_value="FarmOS доступний."):
with patch("agromatrix_tools.tool_farmos_read._farmos_search_assets_impl",
return_value=_sample_asset_text(["Поле 1"])):
result = handle_farm_command(["state"], chat_id=None)
assert "Farm state" in result["summary"] or "FarmOS" in result["summary"]
def test_never_raises_on_exception(self, monkeypatch):
"""handle_farm_command ніколи не кидає виняток."""
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
monkeypatch.setenv("FARMOS_TOKEN", "tok")
with patch("agromatrix_tools.tool_farmos_read._farmos_ping_impl",
side_effect=RuntimeError("boom")):
result = handle_farm_command(["state"], chat_id="chat1")
assert isinstance(result, dict)
assert "summary" in result
# ─── _parse_asset_lines ───────────────────────────────────────────────────────
class TestParseAssetLines:
def test_normal_lines(self):
text = "- Поле А | asset--land | id=abc12345\n- Поле Б | asset--land | id=def56789"
lines = _parse_asset_lines(text)
assert len(lines) == 2
assert all(l.startswith("- ") for l in lines)
def test_ignores_non_dash_lines(self):
text = "FarmOS: нічого не знайдено.\nsome garbage"
lines = _parse_asset_lines(text)
assert lines == []
def test_empty_string(self):
assert _parse_asset_lines("") == []
def test_mixed_content(self):
text = "- Поле 1 | asset--land | id=aaa\nFarmOS: записів не знайдено.\n- Поле 2 | asset--land | id=bbb"
lines = _parse_asset_lines(text)
assert len(lines) == 2
def test_never_raises(self):
result = _parse_asset_lines(None) # type: ignore[arg-type]
assert isinstance(result, list)
# ─── _label_from_asset_line ───────────────────────────────────────────────────
class TestLabelFromAssetLine:
def test_normal(self):
assert _label_from_asset_line("- Поле Північ | asset--land | id=abc") == "Поле Північ"
def test_no_pipe(self):
assert _label_from_asset_line("- Просто назва") == "Просто назва"
def test_strips_dash(self):
result = _label_from_asset_line("- Тест | x | y")
assert result == "Тест"
def test_empty(self):
result = _label_from_asset_line("")
assert isinstance(result, str)
# ─── _build_snapshot_text ─────────────────────────────────────────────────────
class TestBuildSnapshotText:
def test_all_zeros_returns_no_data(self):
text = _build_snapshot_text(
{"asset_land": 0, "asset_plant": 0, "asset_equipment": 0},
{"asset_land": [], "asset_plant": [], "asset_equipment": []},
)
assert "немає даних" in text
def test_with_counts_shows_label(self):
text = _build_snapshot_text(
{"asset_land": 3, "asset_plant": 1, "asset_equipment": 0},
{"asset_land": ["Поле А", "Поле Б", "Поле В"], "asset_plant": ["Кукурудза"], "asset_equipment": []},
)
assert "Поля" in text
assert "3" in text
assert "Поле А" in text
def test_max_chars_respected(self):
tops = {"asset_land": ["А" * 200], "asset_plant": [], "asset_equipment": []}
counts = {"asset_land": 1, "asset_plant": 0, "asset_equipment": 0}
text = _build_snapshot_text(counts, tops)
assert len(text) <= 900
def test_starts_with_header(self):
text = _build_snapshot_text(
{"asset_land": 1, "asset_plant": 0, "asset_equipment": 0},
{"asset_land": ["Поле"], "asset_plant": [], "asset_equipment": []},
)
assert text.startswith("Farm state")
def test_top_3_labels_shown(self):
tops = {"asset_land": ["A", "B", "C", "D"], "asset_plant": [], "asset_equipment": []}
counts = {"asset_land": 4, "asset_plant": 0, "asset_equipment": 0}
text = _build_snapshot_text(counts, tops)
assert "A" in text
assert "B" in text
assert "C" in text
# D — 4-й, має не показуватись
assert "D" not in text
# ─── _save_farm_state_snapshot ────────────────────────────────────────────────
class TestSaveFarmStateSnapshot:
def test_returns_true_on_200(self):
mock_resp = MagicMock()
mock_resp.status_code = 200
with patch("httpx.post", return_value=mock_resp):
result = _save_farm_state_snapshot(
"chat123",
{"asset_land": 2, "asset_plant": 0, "asset_equipment": 0},
{"asset_land": ["Поле"], "asset_plant": [], "asset_equipment": []},
"Farm state (FarmOS):\n- Поля: 2",
)
assert result is True
def test_returns_true_on_201(self):
mock_resp = MagicMock()
mock_resp.status_code = 201
with patch("httpx.post", return_value=mock_resp):
result = _save_farm_state_snapshot("chat1", {}, {}, "text")
assert result is True
def test_returns_false_on_500(self):
mock_resp = MagicMock()
mock_resp.status_code = 500
with patch("httpx.post", return_value=mock_resp):
result = _save_farm_state_snapshot("chat1", {}, {}, "text")
assert result is False
def test_returns_false_on_exception(self):
with patch("httpx.post", side_effect=RuntimeError("network fail")):
result = _save_farm_state_snapshot("chat1", {}, {}, "text")
assert result is False
def test_payload_contains_required_fields(self):
captured = {}
mock_resp = MagicMock()
mock_resp.status_code = 200
def _capture_post(url, *, json, timeout):
captured.update(json)
return mock_resp
with patch("httpx.post", side_effect=_capture_post):
_save_farm_state_snapshot(
"chat999",
{"asset_land": 1, "asset_plant": 0, "asset_equipment": 0},
{"asset_land": ["Поле"], "asset_plant": [], "asset_equipment": []},
"Farm state (FarmOS):\n- Поля: 1",
)
assert captured.get("user_id") == "farm:chat999"
assert captured.get("fact_key") == "farm_state:agromatrix:chat:chat999"
val = captured.get("fact_value_json", {})
assert val.get("source") == "farmos"
assert val.get("_version") == 1
assert "generated_at" in val
assert "counts" in val
assert "top" in val
assert "text" in val
def test_payload_no_full_uuid(self):
"""top labels не містять повних UUID — тільки short_id або відсутній."""
captured = {}
mock_resp = MagicMock()
mock_resp.status_code = 200
def _capture_post(url, *, json, timeout):
captured.update(json)
return mock_resp
full_uuid = "12345678-abcd-ef01-2345-67890abcdef0"
with patch("httpx.post", side_effect=_capture_post):
_save_farm_state_snapshot(
"chatX",
{"asset_land": 1, "asset_plant": 0, "asset_equipment": 0},
{"asset_land": ["Поле А"], "asset_plant": [], "asset_equipment": []},
"text",
)
val_str = json.dumps(captured)
assert full_uuid not in val_str
def test_memory_url_from_env(self, monkeypatch):
"""Використовує AGX_MEMORY_SERVICE_URL з env."""
monkeypatch.setenv("AGX_MEMORY_SERVICE_URL", "http://custom-memory:9999")
captured_url = {}
mock_resp = MagicMock()
mock_resp.status_code = 200
def _capture_post(url, *, json, timeout):
captured_url["url"] = url
return mock_resp
with patch("httpx.post", side_effect=_capture_post):
_save_farm_state_snapshot("c1", {}, {}, "t")
assert "custom-memory:9999" in captured_url.get("url", "")
def test_never_reveals_farmos_url(self, monkeypatch):
"""URL farmOS ніколи не потрапляє у payload."""
monkeypatch.setenv("FARMOS_BASE_URL", "http://secret-farmos.internal")
captured = {}
mock_resp = MagicMock()
mock_resp.status_code = 200
def _capture_post(url, *, json, timeout):
captured.update(json)
return mock_resp
with patch("httpx.post", side_effect=_capture_post):
_save_farm_state_snapshot("c1", {"asset_land": 1}, {"asset_land": ["F"]}, "text")
val_str = json.dumps(captured)
assert "secret-farmos" not in val_str