harness.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463
  1. # -*- coding: utf-8 -*-
  2. """
  3. Plugin Test-Harness.
  4. Bietet eine isolierte Testumgebung für Plugin-Tests.
  5. """
  6. from __future__ import annotations
  7. import asyncio
  8. from dataclasses import dataclass, field
  9. from pathlib import Path
  10. from typing import Any, TYPE_CHECKING
  11. if TYPE_CHECKING:
  12. from trixy_core.plugins.plugin_manager import PluginManager
  13. from trixy_core.plugins.base import TrixyPlugin
  14. @dataclass
  15. class TestContext:
  16. """
  17. Kontext für einen Plugin-Test.
  18. Enthält alle Mock-Objekte und Hilfsmethoden für Tests.
  19. Attributes:
  20. plugin: Das zu testende Plugin
  21. events: Liste der emittierten Events
  22. logs: Liste der Log-Nachrichten
  23. config: Test-Konfiguration
  24. state: Beliebiger Test-Zustand
  25. """
  26. plugin: Any = None
  27. events: list[dict[str, Any]] = field(default_factory=list)
  28. logs: list[dict[str, Any]] = field(default_factory=list)
  29. config: dict[str, Any] = field(default_factory=dict)
  30. state: dict[str, Any] = field(default_factory=dict)
  31. def clear_events(self) -> None:
  32. """Löscht alle aufgezeichneten Events."""
  33. self.events.clear()
  34. def clear_logs(self) -> None:
  35. """Löscht alle aufgezeichneten Logs."""
  36. self.logs.clear()
  37. def clear_all(self) -> None:
  38. """Löscht alle aufgezeichneten Daten."""
  39. self.clear_events()
  40. self.clear_logs()
  41. self.state.clear()
  42. def get_events_by_name(self, name: str) -> list[dict[str, Any]]:
  43. """Gibt Events mit einem bestimmten Namen zurück."""
  44. return [e for e in self.events if e.get("name") == name]
  45. def has_event(self, name: str) -> bool:
  46. """Prüft, ob ein Event mit diesem Namen existiert."""
  47. return any(e.get("name") == name for e in self.events)
  48. def get_last_event(self) -> dict[str, Any] | None:
  49. """Gibt das letzte Event zurück."""
  50. return self.events[-1] if self.events else None
  51. def get_logs_by_level(self, level: str) -> list[dict[str, Any]]:
  52. """Gibt Logs mit einem bestimmten Level zurück."""
  53. return [log for log in self.logs if log.get("level") == level]
  54. def set_state(self, key: str, value: Any) -> None:
  55. """Setzt einen Zustandswert."""
  56. self.state[key] = value
  57. def get_state(self, key: str, default: Any = None) -> Any:
  58. """Gibt einen Zustandswert zurück."""
  59. return self.state.get(key, default)
  60. class PluginTestHarness:
  61. """
  62. Test-Harness für Plugin-Tests.
  63. Bietet eine isolierte Umgebung zum Testen von Plugins
  64. ohne die vollständige Anwendung zu starten.
  65. Example:
  66. # Test-Harness erstellen
  67. harness = PluginTestHarness()
  68. # Plugin laden
  69. await harness.load_plugin(MyPlugin)
  70. # Plugin testen
  71. context = harness.context
  72. await harness.emit_event("test_event", {"data": "test"})
  73. # Ergebnisse prüfen
  74. assert context.has_event("response_event")
  75. # Aufräumen
  76. await harness.cleanup()
  77. """
  78. def __init__(
  79. self,
  80. config: dict[str, Any] | None = None,
  81. plugin_dir: Path | None = None,
  82. ) -> None:
  83. """
  84. Initialisiert das Test-Harness.
  85. Args:
  86. config: Optionale Basis-Konfiguration
  87. plugin_dir: Optionales Plugin-Verzeichnis
  88. """
  89. self._config = config or {}
  90. self._plugin_dir = plugin_dir
  91. self._context = TestContext(config=self._config.copy())
  92. self._plugin: Any = None
  93. self._mock_app: Any = None
  94. self._mock_event_manager: Any = None
  95. self._event_handlers: dict[str, list[Any]] = {}
  96. self._running = False
  97. @property
  98. def context(self) -> TestContext:
  99. """Der Test-Kontext."""
  100. return self._context
  101. @property
  102. def plugin(self) -> Any:
  103. """Das geladene Plugin."""
  104. return self._plugin
  105. @property
  106. def is_running(self) -> bool:
  107. """Läuft das Harness?"""
  108. return self._running
  109. async def load_plugin(
  110. self,
  111. plugin_class: type,
  112. config: dict[str, Any] | None = None,
  113. ) -> Any:
  114. """
  115. Lädt ein Plugin in das Test-Harness.
  116. Args:
  117. plugin_class: Die Plugin-Klasse
  118. config: Plugin-spezifische Konfiguration
  119. Returns:
  120. Die Plugin-Instanz
  121. """
  122. from trixy_core.plugins.testing.mocks import (
  123. MockApplication,
  124. MockEventManager,
  125. )
  126. # Mock-Objekte erstellen
  127. self._mock_event_manager = MockEventManager()
  128. self._mock_event_manager.set_emit_callback(self._on_event_emitted)
  129. self._mock_app = MockApplication(
  130. event_manager=self._mock_event_manager,
  131. )
  132. # Plugin-Konfiguration vorbereiten
  133. plugin_config = {**self._config, **(config or {})}
  134. # Plugin instanziieren
  135. self._plugin = plugin_class(
  136. application=self._mock_app,
  137. config=plugin_config,
  138. )
  139. self._context.plugin = self._plugin
  140. self._context.config = plugin_config
  141. return self._plugin
  142. async def start_plugin(self) -> bool:
  143. """
  144. Startet das geladene Plugin.
  145. Returns:
  146. True bei Erfolg
  147. """
  148. if self._plugin is None:
  149. return False
  150. if self._running:
  151. return True
  152. try:
  153. # Plugin-Lifecycle aufrufen
  154. if hasattr(self._plugin, "on_pre_start"):
  155. await self._call_method(self._plugin.on_pre_start)
  156. if hasattr(self._plugin, "on_start"):
  157. await self._call_method(self._plugin.on_start)
  158. if hasattr(self._plugin, "on_post_start"):
  159. await self._call_method(self._plugin.on_post_start)
  160. self._running = True
  161. return True
  162. except Exception as e:
  163. self._log("error", f"Plugin-Start fehlgeschlagen: {e}")
  164. return False
  165. async def stop_plugin(self) -> bool:
  166. """
  167. Stoppt das geladene Plugin.
  168. Returns:
  169. True bei Erfolg
  170. """
  171. if self._plugin is None:
  172. return False
  173. if not self._running:
  174. return True
  175. try:
  176. # Plugin-Lifecycle aufrufen
  177. if hasattr(self._plugin, "on_pre_stop"):
  178. await self._call_method(self._plugin.on_pre_stop)
  179. if hasattr(self._plugin, "on_stop"):
  180. await self._call_method(self._plugin.on_stop)
  181. if hasattr(self._plugin, "on_post_stop"):
  182. await self._call_method(self._plugin.on_post_stop)
  183. self._running = False
  184. return True
  185. except Exception as e:
  186. self._log("error", f"Plugin-Stop fehlgeschlagen: {e}")
  187. return False
  188. async def emit_event(
  189. self,
  190. name: str,
  191. data: dict[str, Any] | None = None,
  192. ) -> list[Any]:
  193. """
  194. Emittiert ein Event an das Plugin.
  195. Args:
  196. name: Event-Name
  197. data: Event-Daten
  198. Returns:
  199. Liste der Handler-Ergebnisse
  200. """
  201. event_data = data or {}
  202. results = []
  203. # Event aufzeichnen
  204. self._context.events.append({
  205. "name": name,
  206. "data": event_data,
  207. "source": "test",
  208. })
  209. # Handler aufrufen
  210. if self._plugin and hasattr(self._plugin, "_event_handlers"):
  211. handlers = getattr(self._plugin, "_event_handlers", {})
  212. if name in handlers:
  213. for handler in handlers[name]:
  214. try:
  215. result = await self._call_method(
  216. handler, name, event_data
  217. )
  218. results.append(result)
  219. except Exception as e:
  220. self._log("error", f"Handler-Fehler: {e}")
  221. return results
  222. async def call_method(
  223. self,
  224. method_name: str,
  225. *args: Any,
  226. **kwargs: Any,
  227. ) -> Any:
  228. """
  229. Ruft eine Methode des Plugins auf.
  230. Args:
  231. method_name: Name der Methode
  232. *args: Argumente
  233. **kwargs: Keyword-Argumente
  234. Returns:
  235. Rückgabewert der Methode
  236. """
  237. if self._plugin is None:
  238. raise RuntimeError("Kein Plugin geladen")
  239. method = getattr(self._plugin, method_name, None)
  240. if method is None:
  241. raise AttributeError(f"Methode nicht gefunden: {method_name}")
  242. return await self._call_method(method, *args, **kwargs)
  243. def set_config(self, key: str, value: Any) -> None:
  244. """Setzt einen Konfigurationswert."""
  245. self._context.config[key] = value
  246. if self._plugin and hasattr(self._plugin, "config"):
  247. self._plugin.config[key] = value
  248. def get_config(self, key: str, default: Any = None) -> Any:
  249. """Gibt einen Konfigurationswert zurück."""
  250. return self._context.config.get(key, default)
  251. async def cleanup(self) -> None:
  252. """Räumt das Test-Harness auf."""
  253. if self._running:
  254. await self.stop_plugin()
  255. self._plugin = None
  256. self._mock_app = None
  257. self._mock_event_manager = None
  258. self._event_handlers.clear()
  259. self._context.clear_all()
  260. def _on_event_emitted(
  261. self,
  262. name: str,
  263. data: dict[str, Any],
  264. ) -> None:
  265. """Callback für emittierte Events."""
  266. self._context.events.append({
  267. "name": name,
  268. "data": data,
  269. "source": "plugin",
  270. })
  271. def _log(self, level: str, message: str) -> None:
  272. """Zeichnet eine Log-Nachricht auf."""
  273. self._context.logs.append({
  274. "level": level,
  275. "message": message,
  276. })
  277. async def _call_method(
  278. self,
  279. method: Any,
  280. *args: Any,
  281. **kwargs: Any,
  282. ) -> Any:
  283. """Ruft eine Methode auf (sync oder async)."""
  284. result = method(*args, **kwargs)
  285. if asyncio.iscoroutine(result):
  286. return await result
  287. return result
  288. def register_event_handler(
  289. self,
  290. event_name: str,
  291. handler: Any,
  292. ) -> None:
  293. """
  294. Registriert einen Event-Handler für Tests.
  295. Args:
  296. event_name: Event-Name
  297. handler: Handler-Funktion
  298. """
  299. if event_name not in self._event_handlers:
  300. self._event_handlers[event_name] = []
  301. self._event_handlers[event_name].append(handler)
  302. def unregister_event_handler(
  303. self,
  304. event_name: str,
  305. handler: Any,
  306. ) -> bool:
  307. """
  308. Entfernt einen Event-Handler.
  309. Args:
  310. event_name: Event-Name
  311. handler: Handler-Funktion
  312. Returns:
  313. True wenn entfernt
  314. """
  315. if event_name in self._event_handlers:
  316. try:
  317. self._event_handlers[event_name].remove(handler)
  318. return True
  319. except ValueError:
  320. pass
  321. return False
  322. async def wait_for_event(
  323. self,
  324. event_name: str,
  325. timeout: float = 5.0,
  326. ) -> dict[str, Any] | None:
  327. """
  328. Wartet auf ein Event.
  329. Args:
  330. event_name: Event-Name
  331. timeout: Timeout in Sekunden
  332. Returns:
  333. Event-Daten oder None bei Timeout
  334. """
  335. start_count = len(self._context.get_events_by_name(event_name))
  336. elapsed = 0.0
  337. interval = 0.05
  338. while elapsed < timeout:
  339. current = self._context.get_events_by_name(event_name)
  340. if len(current) > start_count:
  341. return current[-1]
  342. await asyncio.sleep(interval)
  343. elapsed += interval
  344. return None
  345. def assert_event_emitted(
  346. self,
  347. event_name: str,
  348. count: int | None = None,
  349. ) -> None:
  350. """
  351. Prüft, ob ein Event emittiert wurde.
  352. Args:
  353. event_name: Event-Name
  354. count: Erwartete Anzahl (None = mindestens 1)
  355. Raises:
  356. AssertionError: Wenn Bedingung nicht erfüllt
  357. """
  358. events = self._context.get_events_by_name(event_name)
  359. if count is None:
  360. assert len(events) > 0, f"Event '{event_name}' wurde nicht emittiert"
  361. else:
  362. assert len(events) == count, (
  363. f"Event '{event_name}' wurde {len(events)} mal emittiert, "
  364. f"erwartet: {count}"
  365. )
  366. def assert_no_errors(self) -> None:
  367. """
  368. Prüft, dass keine Fehler aufgezeichnet wurden.
  369. Raises:
  370. AssertionError: Wenn Fehler vorhanden
  371. """
  372. errors = self._context.get_logs_by_level("error")
  373. assert len(errors) == 0, f"Fehler aufgetreten: {errors}"