test_activity_tracker.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606
  1. # -*- coding: utf-8 -*-
  2. """
  3. Tests fuer den ActivityTracker Service.
  4. Testet Persistenz, Cleanup, Event-Listener und Query-Methoden.
  5. """
  6. import asyncio
  7. import json
  8. import time
  9. from datetime import datetime, timedelta, timezone
  10. from pathlib import Path
  11. from unittest.mock import AsyncMock, MagicMock
  12. import pytest
  13. from trixy_core.activity.models import (
  14. SatelliteConnectionEntry,
  15. ConfigConnectionEntry,
  16. TrainingRunEntry,
  17. IntentLogEntry,
  18. ErrorLogEntry,
  19. ConversationEntry,
  20. )
  21. from trixy_core.activity.tracker import ActivityTracker, _now_iso, _parse_iso
  22. # ============================================================================
  23. # Fixtures
  24. # ============================================================================
  25. @pytest.fixture
  26. def mock_app():
  27. """Mock-Application mit EventManager und ServiceContainer."""
  28. app = MagicMock()
  29. app.events = MagicMock()
  30. app.events.register = MagicMock()
  31. app.services = MagicMock()
  32. app.services.get_service = MagicMock(return_value=None)
  33. return app
  34. @pytest.fixture
  35. def tracker(mock_app, tmp_path):
  36. """ActivityTracker mit temporaerem Verzeichnis."""
  37. t = ActivityTracker(mock_app, retention_days=7, data_directory=str(tmp_path / "activity"))
  38. return t
  39. @pytest.fixture
  40. async def started_tracker(tracker):
  41. """Gestarteter ActivityTracker (Tasks werden nach Test aufgeraeumt)."""
  42. await tracker.start()
  43. yield tracker
  44. await tracker.stop()
  45. # ============================================================================
  46. # Hilfsfunktionen
  47. # ============================================================================
  48. def _make_satellite_connected_event(satellite_id="sat-1", alias="Wohnzimmer",
  49. room="wohnzimmer", ip="192.168.1.10"):
  50. """Erstellt ein Mock-SatelliteConnected Event."""
  51. event = MagicMock()
  52. event.satellite_id = satellite_id
  53. event.alias = alias
  54. event.room = room
  55. event.ip_address = ip
  56. return event
  57. def _make_satellite_disconnected_event(satellite_id="sat-1", reason="timeout"):
  58. event = MagicMock()
  59. event.satellite_id = satellite_id
  60. event.reason = reason
  61. return event
  62. def _make_intent_handled_event(intent="test_intent", satellite_id="sat-1",
  63. room_id="wohnzimmer", text="test anfrage",
  64. success=True):
  65. event = MagicMock()
  66. event.intent = intent
  67. event.satellite_id = satellite_id
  68. event.room_id = room_id
  69. event.original_text = text
  70. event.success = success
  71. return event
  72. def _make_conversation_started_event(conversation_id="conv-1", satellite_id="sat-1"):
  73. event = MagicMock()
  74. event.conversation_id = conversation_id
  75. event.satellite_id = satellite_id
  76. return event
  77. def _make_conversation_ended_event(conversation_id="conv-1", satellite_id="sat-1",
  78. reason="completed"):
  79. event = MagicMock()
  80. event.conversation_id = conversation_id
  81. event.satellite_id = satellite_id
  82. event.reason = reason
  83. return event
  84. # ============================================================================
  85. # Tests: Modelle
  86. # ============================================================================
  87. class TestModels:
  88. """Tests fuer die Datenklassen."""
  89. def test_satellite_connection_entry_defaults(self):
  90. entry = SatelliteConnectionEntry()
  91. assert entry.satellite_id == ""
  92. assert entry.alias == ""
  93. assert entry.room == ""
  94. assert entry.connected_at == ""
  95. assert entry.disconnected_at == ""
  96. assert entry.ip_address == ""
  97. def test_config_connection_entry_defaults(self):
  98. entry = ConfigConnectionEntry()
  99. assert entry.peer_address == ""
  100. assert entry.connected_at == ""
  101. assert entry.disconnected_at == ""
  102. def test_training_run_entry_defaults(self):
  103. entry = TrainingRunEntry()
  104. assert entry.trainer_id == ""
  105. assert entry.duration_seconds == 0.0
  106. assert entry.success is False
  107. def test_intent_log_entry_defaults(self):
  108. entry = IntentLogEntry()
  109. assert entry.intent == ""
  110. assert entry.plugin == ""
  111. assert entry.success is True
  112. def test_error_log_entry_defaults(self):
  113. entry = ErrorLogEntry()
  114. assert entry.source == ""
  115. assert entry.message == ""
  116. def test_conversation_entry_defaults(self):
  117. entry = ConversationEntry()
  118. assert entry.session_id == ""
  119. assert entry.turns == []
  120. # ============================================================================
  121. # Tests: Hilfsfunktionen
  122. # ============================================================================
  123. class TestHelpers:
  124. """Tests fuer _now_iso und _parse_iso."""
  125. def test_now_iso_format(self):
  126. result = _now_iso()
  127. # Muss als ISO parsbar sein
  128. dt = datetime.fromisoformat(result)
  129. assert dt.tzinfo is not None # Muss UTC enthalten
  130. def test_parse_iso_valid(self):
  131. iso = "2026-03-15T10:30:00+00:00"
  132. dt = _parse_iso(iso)
  133. assert dt is not None
  134. assert dt.year == 2026
  135. assert dt.month == 3
  136. def test_parse_iso_empty(self):
  137. assert _parse_iso("") is None
  138. def test_parse_iso_invalid(self):
  139. assert _parse_iso("not-a-date") is None
  140. # ============================================================================
  141. # Tests: Lifecycle
  142. # ============================================================================
  143. class TestLifecycle:
  144. """Tests fuer Start/Stop."""
  145. @pytest.mark.asyncio
  146. async def test_start_creates_directory(self, tracker, tmp_path):
  147. """Start erstellt data/activity/ Verzeichnis."""
  148. await tracker.start()
  149. assert (tmp_path / "activity").exists()
  150. await tracker.stop()
  151. @pytest.mark.asyncio
  152. async def test_start_registers_event_listeners(self, tracker):
  153. """Start registriert alle Event-Listener."""
  154. await tracker.start()
  155. calls = tracker._application.events.register.call_args_list
  156. event_names = [c[0][0] for c in calls]
  157. assert "satellite_connected" in event_names
  158. assert "satellite_disconnected" in event_names
  159. assert "intent_handled" in event_names
  160. assert "conversation_started" in event_names
  161. assert "conversation_ended" in event_names
  162. await tracker.stop()
  163. @pytest.mark.asyncio
  164. async def test_stop_cancels_tasks(self, tracker):
  165. """Stop beendet Flush- und Cleanup-Tasks."""
  166. await tracker.start()
  167. assert tracker._flush_task is not None
  168. assert tracker._cleanup_task is not None
  169. await tracker.stop()
  170. assert tracker._flush_task is None
  171. assert tracker._cleanup_task is None
  172. # ============================================================================
  173. # Tests: Event-Listener
  174. # ============================================================================
  175. class TestEventListeners:
  176. """Tests fuer Event-Handler."""
  177. @pytest.mark.asyncio
  178. async def test_satellite_connected(self, started_tracker):
  179. """satellite_connected erstellt neuen Eintrag."""
  180. event = _make_satellite_connected_event()
  181. await started_tracker._on_satellite_connected("satellite_connected", event)
  182. assert len(started_tracker._satellite_connections) == 1
  183. entry = started_tracker._satellite_connections[0]
  184. assert entry["satellite_id"] == "sat-1"
  185. assert entry["alias"] == "Wohnzimmer"
  186. assert entry["room"] == "wohnzimmer"
  187. assert entry["ip_address"] == "192.168.1.10"
  188. assert entry["connected_at"] != ""
  189. assert entry["disconnected_at"] == ""
  190. @pytest.mark.asyncio
  191. async def test_satellite_disconnected(self, started_tracker):
  192. """satellite_disconnected setzt disconnected_at."""
  193. event_conn = _make_satellite_connected_event()
  194. await started_tracker._on_satellite_connected("satellite_connected", event_conn)
  195. event_disc = _make_satellite_disconnected_event()
  196. await started_tracker._on_satellite_disconnected("satellite_disconnected", event_disc)
  197. entry = started_tracker._satellite_connections[0]
  198. assert entry["disconnected_at"] != ""
  199. @pytest.mark.asyncio
  200. async def test_satellite_disconnected_no_match(self, started_tracker):
  201. """satellite_disconnected bei unbekanntem Satellite aendert nichts."""
  202. event = _make_satellite_disconnected_event(satellite_id="unknown")
  203. await started_tracker._on_satellite_disconnected("satellite_disconnected", event)
  204. # Kein Fehler, keine Aenderung
  205. @pytest.mark.asyncio
  206. async def test_intent_handled(self, started_tracker):
  207. """intent_handled erstellt neuen Intent-Log-Eintrag."""
  208. event = _make_intent_handled_event()
  209. await started_tracker._on_intent_handled("intent_handled", event)
  210. assert len(started_tracker._intent_log) == 1
  211. entry = started_tracker._intent_log[0]
  212. assert entry["intent"] == "test_intent"
  213. assert entry["satellite_id"] == "sat-1"
  214. assert entry["room_id"] == "wohnzimmer"
  215. assert entry["original_text"] == "test anfrage"
  216. assert entry["success"] is True
  217. @pytest.mark.asyncio
  218. async def test_conversation_started(self, started_tracker):
  219. """conversation_started erstellt neuen Conversation-Eintrag."""
  220. event = _make_conversation_started_event()
  221. await started_tracker._on_conversation_started("conversation_started", event)
  222. assert len(started_tracker._conversations) == 1
  223. entry = started_tracker._conversations[0]
  224. assert entry["session_id"] == "conv-1"
  225. assert entry["satellite_id"] == "sat-1"
  226. assert entry["started_at"] != ""
  227. assert entry["completed_at"] == ""
  228. @pytest.mark.asyncio
  229. async def test_conversation_ended(self, started_tracker):
  230. """conversation_ended setzt completed_at."""
  231. event_start = _make_conversation_started_event()
  232. await started_tracker._on_conversation_started("conversation_started", event_start)
  233. event_end = _make_conversation_ended_event()
  234. await started_tracker._on_conversation_ended("conversation_ended", event_end)
  235. entry = started_tracker._conversations[0]
  236. assert entry["completed_at"] != ""
  237. # ============================================================================
  238. # Tests: Oeffentliche Logging-Methoden
  239. # ============================================================================
  240. class TestLoggingMethods:
  241. """Tests fuer externe Logging-Methoden."""
  242. @pytest.mark.asyncio
  243. async def test_log_config_connection(self, started_tracker):
  244. """log_config_connection erstellt neuen Eintrag."""
  245. started_tracker.log_config_connection("192.168.1.100:54321")
  246. assert len(started_tracker._config_connections) == 1
  247. entry = started_tracker._config_connections[0]
  248. assert entry["peer_address"] == "192.168.1.100:54321"
  249. assert entry["connected_at"] != ""
  250. @pytest.mark.asyncio
  251. async def test_log_config_disconnection(self, started_tracker):
  252. """log_config_disconnection setzt disconnected_at."""
  253. started_tracker.log_config_connection("192.168.1.100:54321")
  254. started_tracker.log_config_disconnection("192.168.1.100:54321")
  255. entry = started_tracker._config_connections[0]
  256. assert entry["disconnected_at"] != ""
  257. @pytest.mark.asyncio
  258. async def test_log_training_start(self, started_tracker):
  259. """log_training_start erstellt neuen Training-Eintrag."""
  260. started_tracker.log_training_start("wakeword", "WakewordModel")
  261. assert len(started_tracker._training_runs) == 1
  262. entry = started_tracker._training_runs[0]
  263. assert entry["trainer_id"] == "wakeword"
  264. assert entry["model_name"] == "WakewordModel"
  265. @pytest.mark.asyncio
  266. async def test_log_training_complete(self, started_tracker):
  267. """log_training_complete setzt completed_at und Ergebnis."""
  268. started_tracker.log_training_start("wakeword", "WakewordModel")
  269. started_tracker.log_training_complete("wakeword", success=True)
  270. entry = started_tracker._training_runs[0]
  271. assert entry["completed_at"] != ""
  272. assert entry["success"] is True
  273. assert entry["duration_seconds"] >= 0
  274. @pytest.mark.asyncio
  275. async def test_log_training_complete_failed(self, started_tracker):
  276. """log_training_complete mit Fehler."""
  277. started_tracker.log_training_start("voice", "VoiceModel")
  278. started_tracker.log_training_complete("voice", success=False, error="GPU Fehler")
  279. entry = started_tracker._training_runs[0]
  280. assert entry["success"] is False
  281. assert entry["error"] == "GPU Fehler"
  282. @pytest.mark.asyncio
  283. async def test_log_error(self, started_tracker):
  284. """log_error erstellt neuen Fehler-Eintrag."""
  285. started_tracker.log_error("NetworkService", "Timeout", satellite_id="sat-1")
  286. assert len(started_tracker._error_log) == 1
  287. entry = started_tracker._error_log[0]
  288. assert entry["source"] == "NetworkService"
  289. assert entry["message"] == "Timeout"
  290. assert entry["satellite_id"] == "sat-1"
  291. # ============================================================================
  292. # Tests: Query-Methoden
  293. # ============================================================================
  294. class TestQueryMethods:
  295. """Tests fuer Query-Methoden."""
  296. @pytest.mark.asyncio
  297. async def test_get_last_intents(self, started_tracker):
  298. """get_last_intents liefert die letzten N Eintraege."""
  299. for i in range(10):
  300. event = _make_intent_handled_event(intent=f"intent_{i}")
  301. await started_tracker._on_intent_handled("intent_handled", event)
  302. result = started_tracker.get_last_intents(3)
  303. assert len(result) == 3
  304. assert result[0]["intent"] == "intent_7"
  305. assert result[2]["intent"] == "intent_9"
  306. @pytest.mark.asyncio
  307. async def test_get_last_intents_empty(self, started_tracker):
  308. """get_last_intents bei leerer Liste."""
  309. result = started_tracker.get_last_intents(5)
  310. assert result == []
  311. @pytest.mark.asyncio
  312. async def test_get_last_intents_by_room(self, started_tracker):
  313. """get_last_intents_by_room filtert nach Raum."""
  314. for room in ["wohnzimmer", "kueche", "wohnzimmer"]:
  315. event = _make_intent_handled_event(room_id=room)
  316. await started_tracker._on_intent_handled("intent_handled", event)
  317. result = started_tracker.get_last_intents_by_room("wohnzimmer", 10)
  318. assert len(result) == 2
  319. result = started_tracker.get_last_intents_by_room("kueche", 10)
  320. assert len(result) == 1
  321. @pytest.mark.asyncio
  322. async def test_get_last_conversations(self, started_tracker):
  323. """get_last_conversations liefert die letzten N Conversations."""
  324. for i in range(5):
  325. event = _make_conversation_started_event(conversation_id=f"conv-{i}")
  326. await started_tracker._on_conversation_started("conversation_started", event)
  327. result = started_tracker.get_last_conversations(2)
  328. assert len(result) == 2
  329. assert result[0]["session_id"] == "conv-3"
  330. @pytest.mark.asyncio
  331. async def test_get_last_errors(self, started_tracker):
  332. """get_last_errors liefert die letzten N Fehler."""
  333. for i in range(5):
  334. started_tracker.log_error("src", f"msg_{i}")
  335. result = started_tracker.get_last_errors(3)
  336. assert len(result) == 3
  337. assert result[0]["message"] == "msg_2"
  338. @pytest.mark.asyncio
  339. async def test_get_error_count_since(self, started_tracker):
  340. """get_error_count_since zaehlt aktuelle Fehler."""
  341. started_tracker.log_error("src", "recent")
  342. assert started_tracker.get_error_count_since(1) == 1
  343. assert started_tracker.get_error_count_since(24) == 1
  344. @pytest.mark.asyncio
  345. async def test_get_last_training(self, started_tracker):
  346. """get_last_training liefert den letzten Trainings-Lauf."""
  347. started_tracker.log_training_start("wakeword", "WW-Model")
  348. started_tracker.log_training_start("voice", "V-Model")
  349. result = started_tracker.get_last_training()
  350. assert result is not None
  351. assert result["trainer_id"] == "voice"
  352. result = started_tracker.get_last_training("wakeword")
  353. assert result is not None
  354. assert result["trainer_id"] == "wakeword"
  355. result = started_tracker.get_last_training("unknown")
  356. assert result is None
  357. @pytest.mark.asyncio
  358. async def test_get_last_config_connection(self, started_tracker):
  359. """get_last_config_connection liefert den letzten Eintrag."""
  360. assert started_tracker.get_last_config_connection() is None
  361. started_tracker.log_config_connection("10.0.0.1:1234")
  362. result = started_tracker.get_last_config_connection()
  363. assert result is not None
  364. assert result["peer_address"] == "10.0.0.1:1234"
  365. @pytest.mark.asyncio
  366. async def test_get_satellite_connection_history(self, started_tracker):
  367. """get_satellite_connection_history filtert nach Alias (case-insensitive)."""
  368. event1 = _make_satellite_connected_event(satellite_id="s1", alias="Wohnzimmer")
  369. event2 = _make_satellite_connected_event(satellite_id="s2", alias="Kueche")
  370. event3 = _make_satellite_connected_event(satellite_id="s1", alias="Wohnzimmer")
  371. await started_tracker._on_satellite_connected("satellite_connected", event1)
  372. await started_tracker._on_satellite_connected("satellite_connected", event2)
  373. await started_tracker._on_satellite_connected("satellite_connected", event3)
  374. result = started_tracker.get_satellite_connection_history("wohnzimmer")
  375. assert len(result) == 2
  376. result = started_tracker.get_satellite_connection_history("KUECHE")
  377. assert len(result) == 1
  378. @pytest.mark.asyncio
  379. async def test_get_intent_stats(self, started_tracker):
  380. """get_intent_stats zaehlt Intents der letzten N Tage."""
  381. for _ in range(3):
  382. event = _make_intent_handled_event(intent="wetter")
  383. await started_tracker._on_intent_handled("intent_handled", event)
  384. for _ in range(2):
  385. event = _make_intent_handled_event(intent="timer")
  386. await started_tracker._on_intent_handled("intent_handled", event)
  387. stats = started_tracker.get_intent_stats(7)
  388. assert stats["wetter"] == 3
  389. assert stats["timer"] == 2
  390. @pytest.mark.asyncio
  391. async def test_clear_all(self, started_tracker):
  392. """clear_all loescht alle Daten."""
  393. started_tracker.log_error("src", "msg")
  394. started_tracker.log_config_connection("peer")
  395. event = _make_intent_handled_event()
  396. await started_tracker._on_intent_handled("intent_handled", event)
  397. started_tracker.clear_all()
  398. assert started_tracker._error_log == []
  399. assert started_tracker._config_connections == []
  400. assert started_tracker._intent_log == []
  401. assert started_tracker._satellite_connections == []
  402. assert started_tracker._training_runs == []
  403. assert started_tracker._conversations == []
  404. # ============================================================================
  405. # Tests: Persistenz
  406. # ============================================================================
  407. class TestPersistence:
  408. """Tests fuer JSON-Persistenz."""
  409. @pytest.mark.asyncio
  410. async def test_flush_creates_json_files(self, tracker, tmp_path):
  411. """Flush erstellt JSON-Dateien."""
  412. await tracker.start()
  413. tracker.log_error("src", "msg")
  414. tracker._flush_all()
  415. error_file = tmp_path / "activity" / "error_log.json"
  416. assert error_file.exists()
  417. data = json.loads(error_file.read_text())
  418. assert len(data) == 1
  419. assert data[0]["source"] == "src"
  420. await tracker.stop()
  421. @pytest.mark.asyncio
  422. async def test_load_on_start(self, mock_app, tmp_path):
  423. """Start laedt bestehende JSON-Dateien."""
  424. # Datei manuell erstellen
  425. activity_dir = tmp_path / "activity"
  426. activity_dir.mkdir()
  427. error_file = activity_dir / "error_log.json"
  428. error_file.write_text(json.dumps([
  429. {"timestamp": _now_iso(), "source": "test", "message": "saved", "satellite_id": ""}
  430. ]))
  431. tracker = ActivityTracker(mock_app, data_directory=str(activity_dir))
  432. await tracker.start()
  433. assert len(tracker._error_log) == 1
  434. assert tracker._error_log[0]["source"] == "test"
  435. await tracker.stop()
  436. @pytest.mark.asyncio
  437. async def test_flush_only_when_dirty(self, tracker, tmp_path):
  438. """Flush schreibt nur wenn Daten geaendert wurden."""
  439. await tracker.start()
  440. tracker._dirty = False
  441. tracker._flush_all()
  442. # Datei sollte nicht existieren (kein Flush ohne dirty)
  443. error_file = tmp_path / "activity" / "error_log.json"
  444. assert not error_file.exists()
  445. await tracker.stop()
  446. @pytest.mark.asyncio
  447. async def test_corrupted_json_handled(self, mock_app, tmp_path):
  448. """Korrupte JSON-Dateien werden graceful behandelt."""
  449. activity_dir = tmp_path / "activity"
  450. activity_dir.mkdir()
  451. (activity_dir / "error_log.json").write_text("NOT VALID JSON{{{")
  452. tracker = ActivityTracker(mock_app, data_directory=str(activity_dir))
  453. await tracker.start()
  454. assert tracker._error_log == []
  455. await tracker.stop()
  456. # ============================================================================
  457. # Tests: Retention / Cleanup
  458. # ============================================================================
  459. class TestRetention:
  460. """Tests fuer Retention-Cleanup."""
  461. @pytest.mark.asyncio
  462. async def test_cleanup_removes_old_entries(self, mock_app, tmp_path):
  463. """Cleanup entfernt Eintraege aelter als retention_days."""
  464. activity_dir = tmp_path / "activity"
  465. activity_dir.mkdir()
  466. old_ts = (datetime.now(timezone.utc) - timedelta(days=10)).isoformat()
  467. recent_ts = _now_iso()
  468. error_file = activity_dir / "error_log.json"
  469. error_file.write_text(json.dumps([
  470. {"timestamp": old_ts, "source": "old", "message": "alt", "satellite_id": ""},
  471. {"timestamp": recent_ts, "source": "new", "message": "neu", "satellite_id": ""},
  472. ]))
  473. tracker = ActivityTracker(mock_app, retention_days=7, data_directory=str(activity_dir))
  474. await tracker.start()
  475. # Alter Eintrag sollte entfernt worden sein
  476. assert len(tracker._error_log) == 1
  477. assert tracker._error_log[0]["source"] == "new"
  478. await tracker.stop()
  479. @pytest.mark.asyncio
  480. async def test_trim_list_max_entries(self, started_tracker):
  481. """trim_list begrenzt auf MAX_ENTRIES."""
  482. # Direkt die interne Liste fuellen
  483. started_tracker._intent_log = [
  484. {"timestamp": _now_iso(), "intent": f"i_{i}", "plugin": "", "satellite_id": "",
  485. "room_id": "", "original_text": "", "success": True}
  486. for i in range(10005)
  487. ]
  488. started_tracker._trim_list(started_tracker._intent_log)
  489. assert len(started_tracker._intent_log) == 10000
  490. # Die aeltesten 5 sollten entfernt worden sein
  491. assert started_tracker._intent_log[0]["intent"] == "i_5"