# -*- coding: utf-8 -*- """ Tests fuer den YAML Intent-Loader. Testet YAML-Laden, Sprach-Fallback, mtime-basiertes Caching, Merge-Logik und Fehlerbehandlung. """ import time import pytest from pathlib import Path from unittest.mock import patch from trixy_core.nlp.intent_loader import ( load_intent_yaml, merge_yaml_into_metadata, clear_cache, _yaml_cache, ) # ============================================================================ # Fixtures # ============================================================================ @pytest.fixture(autouse=True) def clear_yaml_cache(): """Leert den YAML-Cache vor und nach jedem Test.""" clear_cache() yield clear_cache() @pytest.fixture def yaml_dir(tmp_path): """Erstellt ein Verzeichnis mit YAML-Testdateien.""" # Deutsche YAML-Datei de_file = tmp_path / "intents_de.yml" de_file.write_text( "current_weather:\n" " patterns:\n" ' - "(wie ist|wie wird) wetter"\n' ' - "wetter [in] {city}"\n' " examples:\n" ' - "Wie ist das Wetter?"\n' ' - "Wetter in Berlin"\n' "\n" "stop:\n" " patterns:\n" ' - "stopp"\n' ' - "halt"\n' " examples:\n" ' - "Stopp"\n', encoding="utf-8", ) # Englische YAML-Datei en_file = tmp_path / "intents_en.yml" en_file.write_text( "current_weather:\n" " patterns:\n" ' - "(what is|how is) [the] weather"\n' ' - "weather [in] {city}"\n' " examples:\n" ' - "What is the weather?"\n' ' - "Weather in London"\n', encoding="utf-8", ) return tmp_path @pytest.fixture def yaml_dir_nur_de(tmp_path): """Verzeichnis mit nur deutscher YAML-Datei.""" de_file = tmp_path / "intents_de.yml" de_file.write_text( "test_intent:\n" " patterns:\n" ' - "test muster"\n' " examples:\n" ' - "Test Beispiel"\n', encoding="utf-8", ) return tmp_path @pytest.fixture def yaml_dir_leer(tmp_path): """Verzeichnis ohne YAML-Dateien.""" return tmp_path # ============================================================================ # YAML laden # ============================================================================ class TestLoadIntentYaml: """Tests fuer load_intent_yaml().""" def test_laden_deutsch(self, yaml_dir): """Deutsche YAML-Datei sollte geladen werden.""" data = load_intent_yaml(yaml_dir, "de") assert "current_weather" in data assert "stop" in data assert len(data["current_weather"]["patterns"]) == 2 assert len(data["current_weather"]["examples"]) == 2 def test_laden_englisch(self, yaml_dir): """Englische YAML-Datei sollte geladen werden.""" data = load_intent_yaml(yaml_dir, "en") assert "current_weather" in data assert "stop" not in data # Nur in DE definiert assert "What is the weather?" in data["current_weather"]["examples"] def test_fallback_auf_deutsch(self, yaml_dir_nur_de): """Bei fehlender Sprach-Datei sollte Fallback auf DE erfolgen.""" data = load_intent_yaml(yaml_dir_nur_de, "en") assert "test_intent" in data assert data["test_intent"]["patterns"] == ["test muster"] def test_kein_fallback_fuer_de(self, yaml_dir_leer): """Wenn DE-Datei fehlt, sollte leeres Dict zurueckkommen.""" data = load_intent_yaml(yaml_dir_leer, "de") assert data == {} def test_leeres_verzeichnis(self, yaml_dir_leer): """Leeres Verzeichnis sollte leeres Dict ergeben.""" data = load_intent_yaml(yaml_dir_leer, "de") assert data == {} def test_ungueltige_yaml(self, tmp_path): """Ungueltige YAML sollte leeres Dict ergeben.""" bad_file = tmp_path / "intents_de.yml" bad_file.write_text("{{invalid yaml: [", encoding="utf-8") data = load_intent_yaml(tmp_path, "de") assert data == {} def test_yaml_kein_dict(self, tmp_path): """YAML die kein Dict ist sollte leeres Dict ergeben.""" bad_file = tmp_path / "intents_de.yml" bad_file.write_text("- item1\n- item2\n", encoding="utf-8") data = load_intent_yaml(tmp_path, "de") assert data == {} def test_patterns_als_strings(self, yaml_dir): """Patterns sollten als Strings geladen werden.""" data = load_intent_yaml(yaml_dir, "de") for pattern in data["current_weather"]["patterns"]: assert isinstance(pattern, str) def test_examples_als_strings(self, yaml_dir): """Examples sollten als Strings geladen werden.""" data = load_intent_yaml(yaml_dir, "de") for example in data["current_weather"]["examples"]: assert isinstance(example, str) def test_intent_ohne_patterns(self, tmp_path): """Intent ohne Patterns sollte nur Examples haben.""" yaml_file = tmp_path / "intents_de.yml" yaml_file.write_text( "simple_intent:\n" " examples:\n" ' - "Einfach"\n', encoding="utf-8", ) data = load_intent_yaml(tmp_path, "de") assert "simple_intent" in data assert "patterns" not in data["simple_intent"] assert "examples" in data["simple_intent"] def test_intent_ohne_examples(self, tmp_path): """Intent ohne Examples sollte nur Patterns haben.""" yaml_file = tmp_path / "intents_de.yml" yaml_file.write_text( "pattern_only:\n" " patterns:\n" ' - "test muster"\n', encoding="utf-8", ) data = load_intent_yaml(tmp_path, "de") assert "pattern_only" in data assert "patterns" in data["pattern_only"] assert "examples" not in data["pattern_only"] # ============================================================================ # Caching # ============================================================================ class TestCaching: """Tests fuer den mtime-basierten Cache.""" def test_cache_wird_genutzt(self, yaml_dir): """Zweiter Aufruf sollte aus dem Cache kommen.""" data1 = load_intent_yaml(yaml_dir, "de") data2 = load_intent_yaml(yaml_dir, "de") # Gleiche Daten assert data1 == data2 # Cache sollte Eintrag haben assert len(_yaml_cache) > 0 def test_cache_invalidiert_bei_aenderung(self, yaml_dir): """Cache sollte bei Datei-Aenderung invalidiert werden.""" data1 = load_intent_yaml(yaml_dir, "de") assert "current_weather" in data1 # Datei aendern time.sleep(0.05) # mtime-Aufloesung sicherstellen yaml_file = yaml_dir / "intents_de.yml" yaml_file.write_text( "new_intent:\n" " patterns:\n" ' - "neues muster"\n', encoding="utf-8", ) data2 = load_intent_yaml(yaml_dir, "de") assert "new_intent" in data2 assert "current_weather" not in data2 def test_clear_cache(self, yaml_dir): """clear_cache() sollte den Cache leeren.""" load_intent_yaml(yaml_dir, "de") assert len(_yaml_cache) > 0 clear_cache() assert len(_yaml_cache) == 0 def test_verschiedene_sprachen_separat_gecached(self, yaml_dir): """Verschiedene Sprachen sollten separat gecached werden.""" data_de = load_intent_yaml(yaml_dir, "de") data_en = load_intent_yaml(yaml_dir, "en") # Unterschiedliche Daten assert data_de != data_en # Beide sollten im Cache sein assert len(_yaml_cache) >= 2 # ============================================================================ # Merge-Logik # ============================================================================ class TestMergeYamlIntoMetadata: """Tests fuer merge_yaml_into_metadata().""" def test_yaml_ueberschreibt_patterns(self): """YAML-Patterns sollten Dekorator-Patterns ersetzen.""" metadata = { "name": "test_intent", "patterns": ["altes muster"], "examples": ["Altes Beispiel"], } yaml_data = { "test_intent": { "patterns": ["neues muster eins", "neues muster zwei"], } } result = merge_yaml_into_metadata(metadata, yaml_data) assert result["patterns"] == ["neues muster eins", "neues muster zwei"] # Examples bleiben (nicht in YAML ueberschrieben) assert result["examples"] == ["Altes Beispiel"] def test_yaml_ueberschreibt_examples(self): """YAML-Examples sollten Dekorator-Examples ersetzen.""" metadata = { "name": "test_intent", "patterns": ["muster"], "examples": ["Alt"], } yaml_data = { "test_intent": { "examples": ["Neu 1", "Neu 2"], } } result = merge_yaml_into_metadata(metadata, yaml_data) assert result["examples"] == ["Neu 1", "Neu 2"] # Patterns bleiben (nicht in YAML ueberschrieben) assert result["patterns"] == ["muster"] def test_yaml_ueberschreibt_beides(self): """YAML sollte beides ersetzen wenn vorhanden.""" metadata = { "name": "test_intent", "patterns": ["alt"], "examples": ["Alt"], } yaml_data = { "test_intent": { "patterns": ["neu"], "examples": ["Neu"], } } result = merge_yaml_into_metadata(metadata, yaml_data) assert result["patterns"] == ["neu"] assert result["examples"] == ["Neu"] def test_kein_match_in_yaml(self): """Ohne Match in YAML sollte Metadata unveraendert bleiben.""" metadata = { "name": "other_intent", "patterns": ["original"], "examples": ["Original"], } yaml_data = { "test_intent": { "patterns": ["ueberschrieben"], } } result = merge_yaml_into_metadata(metadata, yaml_data) assert result["patterns"] == ["original"] assert result["examples"] == ["Original"] def test_leerer_name(self): """Metadata ohne Name sollte unveraendert bleiben.""" metadata = { "name": "", "patterns": ["muster"], } yaml_data = {"": {"patterns": ["test"]}} result = merge_yaml_into_metadata(metadata, yaml_data) assert result["patterns"] == ["muster"] def test_leere_yaml_data(self): """Leere YAML-Daten sollten Metadata unveraendert lassen.""" metadata = { "name": "test_intent", "patterns": ["muster"], } result = merge_yaml_into_metadata(metadata, {}) assert result["patterns"] == ["muster"] def test_in_place_modifikation(self): """Merge sollte das gleiche Dict zurueckgeben (in-place).""" metadata = { "name": "test_intent", "patterns": ["alt"], } yaml_data = {"test_intent": {"patterns": ["neu"]}} result = merge_yaml_into_metadata(metadata, yaml_data) assert result is metadata # Gleiche Referenz # ============================================================================ # Echte YAML-Dateien # ============================================================================ class TestEchteYamlDateien: """Tests mit den echten YAML-Dateien des Projekts.""" def _find_project_root(self) -> Path: """Findet das Projekt-Root-Verzeichnis.""" current = Path(__file__).parent.parent if (current / "trixy_core").exists(): return current return current def test_system_intents_yaml_existiert(self): """System-Intents YAML-Datei sollte existieren.""" root = self._find_project_root() yaml_path = root / "trixy_core" / "nlp" / "intents_de.yml" assert yaml_path.exists(), f"System-Intents YAML nicht gefunden: {yaml_path}" def test_system_intents_yaml_ladbar(self): """System-Intents YAML sollte ladbar sein.""" root = self._find_project_root() nlp_dir = root / "trixy_core" / "nlp" data = load_intent_yaml(nlp_dir, "de") assert len(data) > 0, "System-Intents YAML ist leer" # Bekannte Intents pruefen for expected in ["stop", "pause", "resume", "help", "get_time"]: assert expected in data, f"Intent '{expected}' fehlt in System-YAML" def test_weather_intents_yaml_existiert(self): """Weather-Plugin YAML-Datei sollte existieren.""" root = self._find_project_root() yaml_path = root / "plugins" / "weather" / "intents_de.yml" assert yaml_path.exists(), f"Weather-Intents YAML nicht gefunden: {yaml_path}" def test_weather_intents_yaml_ladbar(self): """Weather-Plugin YAML sollte ladbar sein.""" root = self._find_project_root() weather_dir = root / "plugins" / "weather" data = load_intent_yaml(weather_dir, "de") assert "current_weather" in data assert "patterns" in data["current_weather"] assert "examples" in data["current_weather"] def test_timer_intents_yaml_ladbar(self): """Timer-Plugin YAML sollte ladbar sein.""" root = self._find_project_root() timer_dir = root / "plugins" / "timer" if not (timer_dir / "intents_de.yml").exists(): pytest.skip("Timer-Intents YAML nicht vorhanden") data = load_intent_yaml(timer_dir, "de") assert "set_timer" in data def test_calendar_intents_yaml_ladbar(self): """Calendar-Plugin YAML sollte ladbar sein.""" root = self._find_project_root() calendar_dir = root / "plugins" / "calendar" if not (calendar_dir / "intents_de.yml").exists(): pytest.skip("Calendar-Intents YAML nicht vorhanden") data = load_intent_yaml(calendar_dir, "de") assert "next_appointment" in data def test_music_intents_yaml_ladbar(self): """Music-Plugin YAML sollte ladbar sein.""" root = self._find_project_root() music_dir = root / "plugins" / "music" if not (music_dir / "intents_de.yml").exists(): pytest.skip("Music-Intents YAML nicht vorhanden") data = load_intent_yaml(music_dir, "de") assert "play_music" in data # ============================================================================ # Fehlerbehandlung # ============================================================================ class TestFehlerbehandlung: """Tests fuer Fehlerbehandlung.""" def test_nicht_existierendes_verzeichnis(self): """Nicht existierendes Verzeichnis sollte leeres Dict ergeben.""" data = load_intent_yaml(Path("/nicht/existent"), "de") assert data == {} def test_yaml_mit_ungueltigem_intent(self, tmp_path): """Ungueltiger Intent-Eintrag (kein Dict) sollte uebersprungen werden.""" yaml_file = tmp_path / "intents_de.yml" yaml_file.write_text( 'valid_intent:\n' ' patterns:\n' ' - "test"\n' 'invalid_intent: "nicht ein dict"\n', encoding="utf-8", ) data = load_intent_yaml(tmp_path, "de") assert "valid_intent" in data assert "invalid_intent" not in data def test_yaml_mit_leeren_patterns(self, tmp_path): """Leere Pattern-Eintraege sollten gefiltert werden.""" yaml_file = tmp_path / "intents_de.yml" yaml_file.write_text( "test_intent:\n" " patterns:\n" ' - "gueltig"\n' " - \n" ' - "auch gueltig"\n', encoding="utf-8", ) data = load_intent_yaml(tmp_path, "de") # None/leere Eintraege sollten gefiltert sein patterns = data["test_intent"]["patterns"] assert all(p for p in patterns) def test_kein_pyyaml_installiert(self, tmp_path): """Fehlende PyYAML-Installation sollte leeres Dict ergeben.""" yaml_file = tmp_path / "intents_de.yml" yaml_file.write_text("test: {}", encoding="utf-8") with patch.dict("sys.modules", {"yaml": None}): # Importfehler simulieren import importlib import trixy_core.nlp.intent_loader as loader_module # Cache leeren damit neu geladen wird clear_cache() # Der Import-Guard in _load_yaml_file faengt das ab # (in der echten Implementierung passiert das beim import yaml)