test_intent_loader.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508
  1. # -*- coding: utf-8 -*-
  2. """
  3. Tests fuer den YAML Intent-Loader.
  4. Testet YAML-Laden, Sprach-Fallback, mtime-basiertes Caching,
  5. Merge-Logik und Fehlerbehandlung.
  6. """
  7. import time
  8. import pytest
  9. from pathlib import Path
  10. from unittest.mock import patch
  11. from trixy_core.nlp.intent_loader import (
  12. load_intent_yaml,
  13. merge_yaml_into_metadata,
  14. clear_cache,
  15. _yaml_cache,
  16. )
  17. # ============================================================================
  18. # Fixtures
  19. # ============================================================================
  20. @pytest.fixture(autouse=True)
  21. def clear_yaml_cache():
  22. """Leert den YAML-Cache vor und nach jedem Test."""
  23. clear_cache()
  24. yield
  25. clear_cache()
  26. @pytest.fixture
  27. def yaml_dir(tmp_path):
  28. """Erstellt ein Verzeichnis mit YAML-Testdateien."""
  29. # Deutsche YAML-Datei
  30. de_file = tmp_path / "intents_de.yml"
  31. de_file.write_text(
  32. "current_weather:\n"
  33. " patterns:\n"
  34. ' - "(wie ist|wie wird) wetter"\n'
  35. ' - "wetter [in] {city}"\n'
  36. " examples:\n"
  37. ' - "Wie ist das Wetter?"\n'
  38. ' - "Wetter in Berlin"\n'
  39. "\n"
  40. "stop:\n"
  41. " patterns:\n"
  42. ' - "stopp"\n'
  43. ' - "halt"\n'
  44. " examples:\n"
  45. ' - "Stopp"\n',
  46. encoding="utf-8",
  47. )
  48. # Englische YAML-Datei
  49. en_file = tmp_path / "intents_en.yml"
  50. en_file.write_text(
  51. "current_weather:\n"
  52. " patterns:\n"
  53. ' - "(what is|how is) [the] weather"\n'
  54. ' - "weather [in] {city}"\n'
  55. " examples:\n"
  56. ' - "What is the weather?"\n'
  57. ' - "Weather in London"\n',
  58. encoding="utf-8",
  59. )
  60. return tmp_path
  61. @pytest.fixture
  62. def yaml_dir_nur_de(tmp_path):
  63. """Verzeichnis mit nur deutscher YAML-Datei."""
  64. de_file = tmp_path / "intents_de.yml"
  65. de_file.write_text(
  66. "test_intent:\n"
  67. " patterns:\n"
  68. ' - "test muster"\n'
  69. " examples:\n"
  70. ' - "Test Beispiel"\n',
  71. encoding="utf-8",
  72. )
  73. return tmp_path
  74. @pytest.fixture
  75. def yaml_dir_leer(tmp_path):
  76. """Verzeichnis ohne YAML-Dateien."""
  77. return tmp_path
  78. # ============================================================================
  79. # YAML laden
  80. # ============================================================================
  81. class TestLoadIntentYaml:
  82. """Tests fuer load_intent_yaml()."""
  83. def test_laden_deutsch(self, yaml_dir):
  84. """Deutsche YAML-Datei sollte geladen werden."""
  85. data = load_intent_yaml(yaml_dir, "de")
  86. assert "current_weather" in data
  87. assert "stop" in data
  88. assert len(data["current_weather"]["patterns"]) == 2
  89. assert len(data["current_weather"]["examples"]) == 2
  90. def test_laden_englisch(self, yaml_dir):
  91. """Englische YAML-Datei sollte geladen werden."""
  92. data = load_intent_yaml(yaml_dir, "en")
  93. assert "current_weather" in data
  94. assert "stop" not in data # Nur in DE definiert
  95. assert "What is the weather?" in data["current_weather"]["examples"]
  96. def test_fallback_auf_deutsch(self, yaml_dir_nur_de):
  97. """Bei fehlender Sprach-Datei sollte Fallback auf DE erfolgen."""
  98. data = load_intent_yaml(yaml_dir_nur_de, "en")
  99. assert "test_intent" in data
  100. assert data["test_intent"]["patterns"] == ["test muster"]
  101. def test_kein_fallback_fuer_de(self, yaml_dir_leer):
  102. """Wenn DE-Datei fehlt, sollte leeres Dict zurueckkommen."""
  103. data = load_intent_yaml(yaml_dir_leer, "de")
  104. assert data == {}
  105. def test_leeres_verzeichnis(self, yaml_dir_leer):
  106. """Leeres Verzeichnis sollte leeres Dict ergeben."""
  107. data = load_intent_yaml(yaml_dir_leer, "de")
  108. assert data == {}
  109. def test_ungueltige_yaml(self, tmp_path):
  110. """Ungueltige YAML sollte leeres Dict ergeben."""
  111. bad_file = tmp_path / "intents_de.yml"
  112. bad_file.write_text("{{invalid yaml: [", encoding="utf-8")
  113. data = load_intent_yaml(tmp_path, "de")
  114. assert data == {}
  115. def test_yaml_kein_dict(self, tmp_path):
  116. """YAML die kein Dict ist sollte leeres Dict ergeben."""
  117. bad_file = tmp_path / "intents_de.yml"
  118. bad_file.write_text("- item1\n- item2\n", encoding="utf-8")
  119. data = load_intent_yaml(tmp_path, "de")
  120. assert data == {}
  121. def test_patterns_als_strings(self, yaml_dir):
  122. """Patterns sollten als Strings geladen werden."""
  123. data = load_intent_yaml(yaml_dir, "de")
  124. for pattern in data["current_weather"]["patterns"]:
  125. assert isinstance(pattern, str)
  126. def test_examples_als_strings(self, yaml_dir):
  127. """Examples sollten als Strings geladen werden."""
  128. data = load_intent_yaml(yaml_dir, "de")
  129. for example in data["current_weather"]["examples"]:
  130. assert isinstance(example, str)
  131. def test_intent_ohne_patterns(self, tmp_path):
  132. """Intent ohne Patterns sollte nur Examples haben."""
  133. yaml_file = tmp_path / "intents_de.yml"
  134. yaml_file.write_text(
  135. "simple_intent:\n"
  136. " examples:\n"
  137. ' - "Einfach"\n',
  138. encoding="utf-8",
  139. )
  140. data = load_intent_yaml(tmp_path, "de")
  141. assert "simple_intent" in data
  142. assert "patterns" not in data["simple_intent"]
  143. assert "examples" in data["simple_intent"]
  144. def test_intent_ohne_examples(self, tmp_path):
  145. """Intent ohne Examples sollte nur Patterns haben."""
  146. yaml_file = tmp_path / "intents_de.yml"
  147. yaml_file.write_text(
  148. "pattern_only:\n"
  149. " patterns:\n"
  150. ' - "test muster"\n',
  151. encoding="utf-8",
  152. )
  153. data = load_intent_yaml(tmp_path, "de")
  154. assert "pattern_only" in data
  155. assert "patterns" in data["pattern_only"]
  156. assert "examples" not in data["pattern_only"]
  157. # ============================================================================
  158. # Caching
  159. # ============================================================================
  160. class TestCaching:
  161. """Tests fuer den mtime-basierten Cache."""
  162. def test_cache_wird_genutzt(self, yaml_dir):
  163. """Zweiter Aufruf sollte aus dem Cache kommen."""
  164. data1 = load_intent_yaml(yaml_dir, "de")
  165. data2 = load_intent_yaml(yaml_dir, "de")
  166. # Gleiche Daten
  167. assert data1 == data2
  168. # Cache sollte Eintrag haben
  169. assert len(_yaml_cache) > 0
  170. def test_cache_invalidiert_bei_aenderung(self, yaml_dir):
  171. """Cache sollte bei Datei-Aenderung invalidiert werden."""
  172. data1 = load_intent_yaml(yaml_dir, "de")
  173. assert "current_weather" in data1
  174. # Datei aendern
  175. time.sleep(0.05) # mtime-Aufloesung sicherstellen
  176. yaml_file = yaml_dir / "intents_de.yml"
  177. yaml_file.write_text(
  178. "new_intent:\n"
  179. " patterns:\n"
  180. ' - "neues muster"\n',
  181. encoding="utf-8",
  182. )
  183. data2 = load_intent_yaml(yaml_dir, "de")
  184. assert "new_intent" in data2
  185. assert "current_weather" not in data2
  186. def test_clear_cache(self, yaml_dir):
  187. """clear_cache() sollte den Cache leeren."""
  188. load_intent_yaml(yaml_dir, "de")
  189. assert len(_yaml_cache) > 0
  190. clear_cache()
  191. assert len(_yaml_cache) == 0
  192. def test_verschiedene_sprachen_separat_gecached(self, yaml_dir):
  193. """Verschiedene Sprachen sollten separat gecached werden."""
  194. data_de = load_intent_yaml(yaml_dir, "de")
  195. data_en = load_intent_yaml(yaml_dir, "en")
  196. # Unterschiedliche Daten
  197. assert data_de != data_en
  198. # Beide sollten im Cache sein
  199. assert len(_yaml_cache) >= 2
  200. # ============================================================================
  201. # Merge-Logik
  202. # ============================================================================
  203. class TestMergeYamlIntoMetadata:
  204. """Tests fuer merge_yaml_into_metadata()."""
  205. def test_yaml_ueberschreibt_patterns(self):
  206. """YAML-Patterns sollten Dekorator-Patterns ersetzen."""
  207. metadata = {
  208. "name": "test_intent",
  209. "patterns": ["altes muster"],
  210. "examples": ["Altes Beispiel"],
  211. }
  212. yaml_data = {
  213. "test_intent": {
  214. "patterns": ["neues muster eins", "neues muster zwei"],
  215. }
  216. }
  217. result = merge_yaml_into_metadata(metadata, yaml_data)
  218. assert result["patterns"] == ["neues muster eins", "neues muster zwei"]
  219. # Examples bleiben (nicht in YAML ueberschrieben)
  220. assert result["examples"] == ["Altes Beispiel"]
  221. def test_yaml_ueberschreibt_examples(self):
  222. """YAML-Examples sollten Dekorator-Examples ersetzen."""
  223. metadata = {
  224. "name": "test_intent",
  225. "patterns": ["muster"],
  226. "examples": ["Alt"],
  227. }
  228. yaml_data = {
  229. "test_intent": {
  230. "examples": ["Neu 1", "Neu 2"],
  231. }
  232. }
  233. result = merge_yaml_into_metadata(metadata, yaml_data)
  234. assert result["examples"] == ["Neu 1", "Neu 2"]
  235. # Patterns bleiben (nicht in YAML ueberschrieben)
  236. assert result["patterns"] == ["muster"]
  237. def test_yaml_ueberschreibt_beides(self):
  238. """YAML sollte beides ersetzen wenn vorhanden."""
  239. metadata = {
  240. "name": "test_intent",
  241. "patterns": ["alt"],
  242. "examples": ["Alt"],
  243. }
  244. yaml_data = {
  245. "test_intent": {
  246. "patterns": ["neu"],
  247. "examples": ["Neu"],
  248. }
  249. }
  250. result = merge_yaml_into_metadata(metadata, yaml_data)
  251. assert result["patterns"] == ["neu"]
  252. assert result["examples"] == ["Neu"]
  253. def test_kein_match_in_yaml(self):
  254. """Ohne Match in YAML sollte Metadata unveraendert bleiben."""
  255. metadata = {
  256. "name": "other_intent",
  257. "patterns": ["original"],
  258. "examples": ["Original"],
  259. }
  260. yaml_data = {
  261. "test_intent": {
  262. "patterns": ["ueberschrieben"],
  263. }
  264. }
  265. result = merge_yaml_into_metadata(metadata, yaml_data)
  266. assert result["patterns"] == ["original"]
  267. assert result["examples"] == ["Original"]
  268. def test_leerer_name(self):
  269. """Metadata ohne Name sollte unveraendert bleiben."""
  270. metadata = {
  271. "name": "",
  272. "patterns": ["muster"],
  273. }
  274. yaml_data = {"": {"patterns": ["test"]}}
  275. result = merge_yaml_into_metadata(metadata, yaml_data)
  276. assert result["patterns"] == ["muster"]
  277. def test_leere_yaml_data(self):
  278. """Leere YAML-Daten sollten Metadata unveraendert lassen."""
  279. metadata = {
  280. "name": "test_intent",
  281. "patterns": ["muster"],
  282. }
  283. result = merge_yaml_into_metadata(metadata, {})
  284. assert result["patterns"] == ["muster"]
  285. def test_in_place_modifikation(self):
  286. """Merge sollte das gleiche Dict zurueckgeben (in-place)."""
  287. metadata = {
  288. "name": "test_intent",
  289. "patterns": ["alt"],
  290. }
  291. yaml_data = {"test_intent": {"patterns": ["neu"]}}
  292. result = merge_yaml_into_metadata(metadata, yaml_data)
  293. assert result is metadata # Gleiche Referenz
  294. # ============================================================================
  295. # Echte YAML-Dateien
  296. # ============================================================================
  297. class TestEchteYamlDateien:
  298. """Tests mit den echten YAML-Dateien des Projekts."""
  299. def _find_project_root(self) -> Path:
  300. """Findet das Projekt-Root-Verzeichnis."""
  301. current = Path(__file__).parent.parent
  302. if (current / "trixy_core").exists():
  303. return current
  304. return current
  305. def test_system_intents_yaml_existiert(self):
  306. """System-Intents YAML-Datei sollte existieren."""
  307. root = self._find_project_root()
  308. yaml_path = root / "trixy_core" / "nlp" / "intents_de.yml"
  309. assert yaml_path.exists(), f"System-Intents YAML nicht gefunden: {yaml_path}"
  310. def test_system_intents_yaml_ladbar(self):
  311. """System-Intents YAML sollte ladbar sein."""
  312. root = self._find_project_root()
  313. nlp_dir = root / "trixy_core" / "nlp"
  314. data = load_intent_yaml(nlp_dir, "de")
  315. assert len(data) > 0, "System-Intents YAML ist leer"
  316. # Bekannte Intents pruefen
  317. for expected in ["stop", "pause", "resume", "help", "get_time"]:
  318. assert expected in data, f"Intent '{expected}' fehlt in System-YAML"
  319. def test_weather_intents_yaml_existiert(self):
  320. """Weather-Plugin YAML-Datei sollte existieren."""
  321. root = self._find_project_root()
  322. yaml_path = root / "plugins" / "weather" / "intents_de.yml"
  323. assert yaml_path.exists(), f"Weather-Intents YAML nicht gefunden: {yaml_path}"
  324. def test_weather_intents_yaml_ladbar(self):
  325. """Weather-Plugin YAML sollte ladbar sein."""
  326. root = self._find_project_root()
  327. weather_dir = root / "plugins" / "weather"
  328. data = load_intent_yaml(weather_dir, "de")
  329. assert "current_weather" in data
  330. assert "patterns" in data["current_weather"]
  331. assert "examples" in data["current_weather"]
  332. def test_timer_intents_yaml_ladbar(self):
  333. """Timer-Plugin YAML sollte ladbar sein."""
  334. root = self._find_project_root()
  335. timer_dir = root / "plugins" / "timer"
  336. if not (timer_dir / "intents_de.yml").exists():
  337. pytest.skip("Timer-Intents YAML nicht vorhanden")
  338. data = load_intent_yaml(timer_dir, "de")
  339. assert "set_timer" in data
  340. def test_calendar_intents_yaml_ladbar(self):
  341. """Calendar-Plugin YAML sollte ladbar sein."""
  342. root = self._find_project_root()
  343. calendar_dir = root / "plugins" / "calendar"
  344. if not (calendar_dir / "intents_de.yml").exists():
  345. pytest.skip("Calendar-Intents YAML nicht vorhanden")
  346. data = load_intent_yaml(calendar_dir, "de")
  347. assert "next_appointment" in data
  348. def test_music_intents_yaml_ladbar(self):
  349. """Music-Plugin YAML sollte ladbar sein."""
  350. root = self._find_project_root()
  351. music_dir = root / "plugins" / "music"
  352. if not (music_dir / "intents_de.yml").exists():
  353. pytest.skip("Music-Intents YAML nicht vorhanden")
  354. data = load_intent_yaml(music_dir, "de")
  355. assert "play_music" in data
  356. # ============================================================================
  357. # Fehlerbehandlung
  358. # ============================================================================
  359. class TestFehlerbehandlung:
  360. """Tests fuer Fehlerbehandlung."""
  361. def test_nicht_existierendes_verzeichnis(self):
  362. """Nicht existierendes Verzeichnis sollte leeres Dict ergeben."""
  363. data = load_intent_yaml(Path("/nicht/existent"), "de")
  364. assert data == {}
  365. def test_yaml_mit_ungueltigem_intent(self, tmp_path):
  366. """Ungueltiger Intent-Eintrag (kein Dict) sollte uebersprungen werden."""
  367. yaml_file = tmp_path / "intents_de.yml"
  368. yaml_file.write_text(
  369. 'valid_intent:\n'
  370. ' patterns:\n'
  371. ' - "test"\n'
  372. 'invalid_intent: "nicht ein dict"\n',
  373. encoding="utf-8",
  374. )
  375. data = load_intent_yaml(tmp_path, "de")
  376. assert "valid_intent" in data
  377. assert "invalid_intent" not in data
  378. def test_yaml_mit_leeren_patterns(self, tmp_path):
  379. """Leere Pattern-Eintraege sollten gefiltert werden."""
  380. yaml_file = tmp_path / "intents_de.yml"
  381. yaml_file.write_text(
  382. "test_intent:\n"
  383. " patterns:\n"
  384. ' - "gueltig"\n'
  385. " - \n"
  386. ' - "auch gueltig"\n',
  387. encoding="utf-8",
  388. )
  389. data = load_intent_yaml(tmp_path, "de")
  390. # None/leere Eintraege sollten gefiltert sein
  391. patterns = data["test_intent"]["patterns"]
  392. assert all(p for p in patterns)
  393. def test_kein_pyyaml_installiert(self, tmp_path):
  394. """Fehlende PyYAML-Installation sollte leeres Dict ergeben."""
  395. yaml_file = tmp_path / "intents_de.yml"
  396. yaml_file.write_text("test: {}", encoding="utf-8")
  397. with patch.dict("sys.modules", {"yaml": None}):
  398. # Importfehler simulieren
  399. import importlib
  400. import trixy_core.nlp.intent_loader as loader_module
  401. # Cache leeren damit neu geladen wird
  402. clear_cache()
  403. # Der Import-Guard in _load_yaml_file faengt das ab
  404. # (in der echten Implementierung passiert das beim import yaml)