# -*- coding: utf-8 -*- """ Tests fuer das Entity-System. Testet EntityDefinition, EntityRegistry, EntityResolver, Datum-/Zeit-/Dauer-Aufloesung und Integration mit dem Matcher. """ import re from datetime import date, time, timedelta import pytest from trixy_core.nlp.entities import ( EntityDefinition, EntityRegistry, EntityResolver, ResolvedEntity, _next_weekday, _parse_absolute_date, _parse_time_string, _format_date_tts, _format_time_tts, _format_duration_tts, ) # ============================================================================ # Fixtures # ============================================================================ @pytest.fixture def registry(): """EntityRegistry mit Testdaten.""" r = EntityRegistry() r.load_from_dict({ "datum": { "open": False, "values": [ "heute", "morgen", "uebermorgen", "übermorgen", "montag", "dienstag", "mittwoch", "donnerstag", "freitag", "samstag", "sonntag", "nächste woche", "am wochenende", ], "regex": [ r"\d{1,2}\.\d{1,2}\.", r"\d{1,2}\.\d{1,2}\.\d{4}", ], }, "zeit": { "open": False, "values": ["mittag", "mitternacht", "abend", "morgens", "nachmittag"], "regex": [ r"\d{1,2} uhr", r"\d{1,2} uhr \d{1,2}", r"\d{1,2}:\d{2}", r"halb \w+", ], }, "dauer": { "open": False, "values": ["eine minute", "eine stunde", "eine halbe stunde"], "regex": [ r"\d+ (sekunden|sekunde)", r"\d+ (minuten|minute)", r"\d+ (stunden|stunde)", ], }, "stadt": { "open": True, "values": ["berlin", "hamburg", "münchen", "muenchen"], }, "person": { "open": True, "values": [], }, }) return r @pytest.fixture def resolver(registry): """EntityResolver mit Registry.""" return EntityResolver(registry) # ============================================================================ # EntityDefinition # ============================================================================ class TestEntityDefinition: """Tests fuer EntityDefinition.""" def test_defaults(self): """Standard-Werte pruefen.""" ed = EntityDefinition(name="test") assert ed.name == "test" assert ed.values == set() assert ed.multi_word_values == set() assert ed.open is False assert ed.regex_patterns == [] def test_mit_werten(self): """Definition mit Werten erstellen.""" ed = EntityDefinition( name="stadt", values={"berlin", "hamburg"}, open=True, ) assert "berlin" in ed.values assert ed.open is True # ============================================================================ # EntityRegistry # ============================================================================ class TestEntityRegistry: """Tests fuer EntityRegistry.""" def test_get_existierend(self, registry): """Existierenden Entity-Typ abrufen.""" entity = registry.get("datum") assert entity is not None assert entity.name == "datum" assert "heute" in entity.values assert "morgen" in entity.values def test_get_nicht_existierend(self, registry): """Nicht existierenden Entity-Typ abrufen.""" assert registry.get("unbekannt") is None def test_matches_bekannter_wert(self, registry): """Bekannter Wert sollte matchen.""" assert registry.matches("heute", "datum") is True assert registry.matches("morgen", "datum") is True assert registry.matches("montag", "datum") is True def test_matches_unbekannter_wert_strikt(self, registry): """Unbekannter Wert bei strikter Entity sollte nicht matchen.""" assert registry.matches("xyz", "datum") is False assert registry.matches("foobar", "zeit") is False def test_matches_unbekannter_wert_offen(self, registry): """Unbekannter Wert bei offener Entity sollte matchen.""" assert registry.matches("tokio", "stadt") is True assert registry.matches("xyz", "stadt") is True def test_matches_mehrwort(self, registry): """Mehrwort-Werte sollten matchen.""" assert registry.matches("nächste woche", "datum") is True assert registry.matches("am wochenende", "datum") is True def test_matches_regex(self, registry): """Regex-Werte sollten matchen.""" assert registry.matches("15.03.", "datum") is True assert registry.matches("1.1.2026", "datum") is True assert registry.matches("15 uhr", "zeit") is True assert registry.matches("9:30", "zeit") is True assert registry.matches("halb drei", "zeit") is True assert registry.matches("5 minuten", "dauer") is True def test_matches_regex_kein_match(self, registry): """Nicht passende Regex sollte nicht matchen.""" assert registry.matches("xyz", "dauer") is False def test_matches_unbekannter_typ(self, registry): """Unbekannter Typ sollte alles akzeptieren.""" assert registry.matches("whatever", "unbekannt") is True def test_mehrwort_match(self, registry): """Mehrwort-Match ab Position testen.""" tokens = ["nächste", "woche"] result = registry.match_multi_word(tokens, 0, "datum") assert result is not None assert result[0] == "nächste woche" assert result[1] == 2 def test_mehrwort_match_kein_treffer(self, registry): """Kein Mehrwort-Match.""" tokens = ["berlin", "test"] result = registry.match_multi_word(tokens, 0, "datum") assert result is None def test_mehrwort_regex_match(self, registry): """Mehrwort-Regex-Match (z.B. '5 minuten').""" tokens = ["5", "minuten"] result = registry.match_multi_word(tokens, 0, "dauer") assert result is not None assert result[0] == "5 minuten" assert result[1] == 2 def test_entity_types(self, registry): """entity_types sollte alle registrierten Typen listen.""" types = registry.entity_types assert "datum" in types assert "zeit" in types assert "dauer" in types assert "stadt" in types assert "person" in types def test_load_from_dict_leer(self): """Leeres Dict sollte keine Fehler verursachen.""" r = EntityRegistry() r.load_from_dict({}) assert r.entity_types == [] # ============================================================================ # EntityResolver — Datum # ============================================================================ class TestResolveDatum: """Tests fuer den Datum-Resolver.""" def test_heute(self, resolver): """'heute' sollte heutiges Datum ergeben.""" result = resolver.resolve("heute", "datum") assert isinstance(result, ResolvedEntity) assert result.entity_type == "datum" assert result.value == date.today() assert result.formatted["iso"] == date.today().isoformat() assert result.formatted["tts"] == "heute" def test_morgen(self, resolver): """'morgen' sollte morgiges Datum ergeben.""" result = resolver.resolve("morgen", "datum") assert result.value == date.today() + timedelta(days=1) assert result.formatted["tts"] == "morgen" def test_uebermorgen(self, resolver): """'uebermorgen' sollte uebermorgen ergeben.""" result = resolver.resolve("uebermorgen", "datum") assert result.value == date.today() + timedelta(days=2) assert result.formatted["tts"] == "uebermorgen" def test_uebermorgen_umlaut(self, resolver): """'übermorgen' mit Umlaut sollte auch funktionieren.""" result = resolver.resolve("übermorgen", "datum") assert result.value == date.today() + timedelta(days=2) def test_wochentag(self, resolver): """Wochentag sollte naechstes Vorkommen ergeben.""" result = resolver.resolve("montag", "datum") assert result.value.weekday() == 0 # Montag assert result.value > date.today() def test_am_wochenende(self, resolver): """'am wochenende' sollte naechsten Samstag ergeben.""" result = resolver.resolve("am wochenende", "datum") assert result.value.weekday() == 5 # Samstag def test_absolutes_datum(self, resolver): """'15.03.' sollte korrekt geparst werden.""" result = resolver.resolve("15.03.", "datum") assert result.value.day == 15 assert result.value.month == 3 def test_absolutes_datum_mit_jahr(self, resolver): """'15.03.2027' sollte korrekt geparst werden.""" result = resolver.resolve("15.03.2027", "datum") assert result.value == date(2027, 3, 15) def test_formatted_eu(self, resolver): """EU-Format sollte DD.MM.YYYY sein.""" result = resolver.resolve("heute", "datum") assert result.formatted["eu"] == date.today().strftime("%d.%m.%Y") def test_formatted_api(self, resolver): """API-Format sollte ISO sein.""" result = resolver.resolve("morgen", "datum") expected = (date.today() + timedelta(days=1)).isoformat() assert result.formatted["api"] == expected def test_morgen_ist_today_plus_1(self, resolver): """'morgen' muss exakt date.today() + timedelta(days=1) sein.""" today = date.today() expected = today + timedelta(days=1) result = resolver.resolve("morgen", "datum") assert result.value == expected, ( f"'morgen' sollte {expected.isoformat()} sein, " f"aber ist {result.value}" ) assert result.formatted["iso"] == expected.isoformat() assert result.formatted["api"] == expected.isoformat() def test_heute_ist_today(self, resolver): """'heute' muss exakt date.today() sein.""" expected = date.today() result = resolver.resolve("heute", "datum") assert result.value == expected def test_uebermorgen_ist_today_plus_2(self, resolver): """'uebermorgen' muss exakt date.today() + 2 sein.""" expected = date.today() + timedelta(days=2) result = resolver.resolve("uebermorgen", "datum") assert result.value == expected def test_wochentag_ist_in_zukunft_und_korrekt(self, resolver): """Wochentag muss in der Zukunft liegen und der richtige Wochentag sein.""" today = date.today() for name, weekday_num in [ ("montag", 0), ("dienstag", 1), ("mittwoch", 2), ("donnerstag", 3), ("freitag", 4), ("samstag", 5), ("sonntag", 6), ]: result = resolver.resolve(name, "datum") assert result.value.weekday() == weekday_num, ( f"'{name}' sollte Wochentag {weekday_num} sein, " f"aber ist {result.value.weekday()}" ) assert result.value > today, ( f"'{name}' sollte nach heute ({today}) liegen" ) # Maximal 7 Tage in der Zukunft assert (result.value - today).days <= 7 def test_morgen_formatted_outputs(self, resolver): """'morgen' muss alle Formatvarianten korrekt liefern.""" result = resolver.resolve("morgen", "datum") tomorrow = date.today() + timedelta(days=1) assert result.formatted["iso"] == tomorrow.isoformat() assert result.formatted["eu"] == tomorrow.strftime("%d.%m.%Y") assert result.formatted["tts"] == "morgen" assert result.formatted["api"] == tomorrow.isoformat() def test_unbekannter_wert(self, resolver): """Unbekannter Wert sollte Fallback liefern.""" result = resolver.resolve("xyz", "datum") assert result.entity_type == "datum" assert result.value == "xyz" # Generischer Resolver def test_raw_erhalten(self, resolver): """Rohwert sollte erhalten bleiben.""" result = resolver.resolve("morgen", "datum") assert result.raw == "morgen" # ============================================================================ # EntityResolver — Zeit # ============================================================================ class TestResolveZeit: """Tests fuer den Zeit-Resolver.""" def test_mittag(self, resolver): """'mittag' sollte 12:00 ergeben.""" result = resolver.resolve("mittag", "zeit") assert result.value == time(12, 0) assert result.formatted["24h"] == "12:00" assert result.formatted["tts"] == "12 Uhr" def test_mitternacht(self, resolver): """'mitternacht' sollte 00:00 ergeben.""" result = resolver.resolve("mitternacht", "zeit") assert result.value == time(0, 0) def test_uhrzeit_format(self, resolver): """'15 uhr' sollte korrekt geparst werden.""" result = resolver.resolve("15 uhr", "zeit") assert result.value == time(15, 0) assert result.formatted["tts"] == "15 Uhr" def test_uhrzeit_mit_minuten(self, resolver): """'15:30' sollte korrekt geparst werden.""" result = resolver.resolve("15:30", "zeit") assert result.value == time(15, 30) assert result.formatted["24h"] == "15:30" def test_uhrzeit_uhr_minuten(self, resolver): """'15 uhr 30' sollte korrekt geparst werden.""" result = resolver.resolve("15 uhr 30", "zeit") assert result.value == time(15, 30) def test_halb_drei(self, resolver): """'halb drei' sollte 2:30 ergeben.""" result = resolver.resolve("halb drei", "zeit") assert result.value == time(2, 30) def test_abend(self, resolver): """'abend' sollte 18:00 ergeben.""" result = resolver.resolve("abend", "zeit") assert result.value == time(18, 0) def test_unbekannte_zeit(self, resolver): """Unbekannte Zeitangabe sollte Fallback liefern.""" result = resolver.resolve("xyz", "zeit") assert result.entity_type == "zeit" assert result.value == "xyz" # ============================================================================ # EntityResolver — Dauer # ============================================================================ class TestResolveDauer: """Tests fuer den Dauer-Resolver.""" def test_minuten(self, resolver): """'5 minuten' sollte 300 Sekunden ergeben.""" result = resolver.resolve("5 minuten", "dauer") assert result.value == 300 assert result.formatted["seconds"] == "300" assert result.formatted["minutes"] == "5" assert result.formatted["tts"] == "5 Minuten" def test_sekunden(self, resolver): """'30 sekunden' sollte 30 Sekunden ergeben.""" result = resolver.resolve("30 sekunden", "dauer") assert result.value == 30 assert result.formatted["seconds"] == "30" assert result.formatted["tts"] == "30 Sekunden" def test_stunden(self, resolver): """'2 stunden' sollte 7200 Sekunden ergeben.""" result = resolver.resolve("2 stunden", "dauer") assert result.value == 7200 assert result.formatted["hours"] == "2" assert result.formatted["tts"] == "2 Stunden" def test_eine_minute(self, resolver): """'eine minute' sollte 60 Sekunden ergeben.""" result = resolver.resolve("eine minute", "dauer") assert result.value == 60 def test_eine_halbe_stunde(self, resolver): """'eine halbe stunde' sollte 1800 Sekunden ergeben.""" result = resolver.resolve("eine halbe stunde", "dauer") assert result.value == 1800 assert result.formatted["minutes"] == "30" def test_eine_stunde(self, resolver): """'eine stunde' sollte 3600 Sekunden ergeben.""" result = resolver.resolve("eine stunde", "dauer") assert result.value == 3600 def test_1_sekunde(self, resolver): """'1 sekunde' sollte 1 Sekunde ergeben.""" result = resolver.resolve("1 sekunde", "dauer") assert result.value == 1 assert result.formatted["tts"] == "1 Sekunde" def test_unbekannte_dauer(self, resolver): """Unbekannte Dauer sollte Fallback liefern.""" result = resolver.resolve("xyz", "dauer") assert result.entity_type == "dauer" assert result.value == "xyz" # ============================================================================ # EntityResolver — Generisch # ============================================================================ class TestResolveGeneric: """Tests fuer den generischen Resolver.""" def test_unbekannter_entity_typ(self, resolver): """Unbekannter Entity-Typ sollte generisch aufgeloest werden.""" result = resolver.resolve("test", "unbekannt") assert result.entity_type == "unbekannt" assert result.value == "test" assert result.formatted["raw"] == "test" def test_stadt(self, resolver): """Stadt (offene Entity, kein spezieller Resolver) → generisch.""" result = resolver.resolve("berlin", "stadt") assert result.entity_type == "stadt" assert result.value == "berlin" def test_raw_wird_getrimmt(self, resolver): """Rohwert wird getrimmt.""" result = resolver.resolve(" berlin ", "stadt") assert result.value == "berlin" # ============================================================================ # Hilfsfunktionen # ============================================================================ class TestHilfsfunktionen: """Tests fuer interne Hilfsfunktionen.""" def test_next_weekday(self): """Naechster Wochentag berechnen.""" today = date.today() # Naechster Montag result = _next_weekday(today, 0) assert result.weekday() == 0 assert result > today def test_parse_absolute_date_dd_mm(self): """Absolutes Datum DD.MM. parsen.""" result = _parse_absolute_date("15.03.") assert result is not None assert result.day == 15 assert result.month == 3 def test_parse_absolute_date_dd_mm_yyyy(self): """Absolutes Datum DD.MM.YYYY parsen.""" result = _parse_absolute_date("15.03.2027") assert result == date(2027, 3, 15) def test_datum_mit_monatname(self): """'5 mai' sollte ein Datum mit Tag 5, Monat 5 ergeben.""" result = _parse_absolute_date("5 mai") assert result is not None assert result.day == 5 assert result.month == 5 def test_datum_mit_monatname_vergangenheit(self): """Vergangenes Datum mit Monatsname sollte naechstes Jahr ergeben.""" today = date.today() # Waehle ein Datum das sicher in der Vergangenheit liegt past_month = today.month - 1 if today.month > 1 else 12 past_day = 1 result = _parse_absolute_date(f"{past_day} {_get_month_name(past_month)}") assert result is not None if past_month < today.month or (past_month == today.month and past_day < today.day): assert result.year == today.year + 1 else: assert result.year == today.year def test_datum_maerz_varianten(self): """'maerz' und 'märz' sollten beide als Maerz erkannt werden.""" result1 = _parse_absolute_date("1 maerz") result2 = _parse_absolute_date("1 märz") assert result1 is not None assert result2 is not None assert result1.month == 3 assert result2.month == 3 assert result1 == result2 def test_parse_absolute_date_ungueltig(self): """Ungueltiges Datum sollte None ergeben.""" assert _parse_absolute_date("abc") is None assert _parse_absolute_date("32.13.") is None def test_parse_time_string_hh_mm(self): """Zeitformat HH:MM parsen.""" assert _parse_time_string("15:30") == (15, 30) assert _parse_time_string("9:00") == (9, 0) def test_parse_time_string_hh_uhr(self): """Zeitformat 'HH Uhr' parsen.""" assert _parse_time_string("15 uhr") == (15, 0) def test_parse_time_string_hh_uhr_mm(self): """Zeitformat 'HH Uhr MM' parsen.""" assert _parse_time_string("15 uhr 30") == (15, 30) def test_parse_time_string_halb(self): """'halb drei' → (2, 30).""" assert _parse_time_string("halb drei") == (2, 30) def test_parse_time_string_ungueltig(self): """Ungueltiger Zeitstring sollte None ergeben.""" assert _parse_time_string("abc") is None def test_format_date_tts(self): """TTS-Formatierung fuer Datum.""" today = date.today() assert _format_date_tts(today) == "heute" assert _format_date_tts(today + timedelta(days=1)) == "morgen" assert _format_date_tts(today + timedelta(days=2)) == "uebermorgen" def test_format_time_tts(self): """TTS-Formatierung fuer Zeit.""" assert _format_time_tts(time(15, 0)) == "15 Uhr" assert _format_time_tts(time(15, 30)) == "15 Uhr 30" def test_format_duration_tts(self): """TTS-Formatierung fuer Dauer.""" assert _format_duration_tts(30) == "30 Sekunden" assert _format_duration_tts(1) == "1 Sekunde" assert _format_duration_tts(60) == "1 Minute" assert _format_duration_tts(300) == "5 Minuten" assert _format_duration_tts(3600) == "1 Stunde" assert _format_duration_tts(7200) == "2 Stunden" assert _format_duration_tts(5400) == "1 Stunde 30 Minuten" # ============================================================================ # Integration: Matcher + Entities # ============================================================================ class TestMatcherIntegration: """Integrationstests: KeywordMatcher mit Entity-System.""" @pytest.fixture def entity_matcher(self, registry): """Matcher mit Entity-Registry.""" from trixy_core.nlp.keyword_matcher import KeywordIntentMatcher m = KeywordIntentMatcher() m.set_entity_registry(registry) return m def test_datum_slot_match(self, entity_matcher): """Pattern mit {date:datum} sollte 'morgen' matchen.""" entity_matcher.compile_intents([{ "name": "weather_forecast", "patterns": ["wetter {date:datum}"], }]) result = entity_matcher.match("Wetter morgen") assert result is not None assert result.intent == "weather_forecast" assert "date" in result.slots slot = result.slots["date"] assert isinstance(slot, ResolvedEntity) assert slot.entity_type == "datum" assert slot.value == date.today() + timedelta(days=1) def test_datum_slot_strikt_ablehnung(self, entity_matcher): """Pattern mit {date:datum} sollte unbekannte Werte ablehnen.""" entity_matcher.compile_intents([{ "name": "weather_forecast", "patterns": ["wetter {date:datum}"], }]) result = entity_matcher.match("Wetter xyz") assert result is None def test_stadt_slot_offen(self, entity_matcher): """Pattern mit {city:stadt} sollte beliebige Werte akzeptieren.""" entity_matcher.compile_intents([{ "name": "current_weather", "patterns": ["wetter [in] {city:stadt}"], }]) result = entity_matcher.match("Wetter in Tokyo") assert result is not None assert "city" in result.slots slot = result.slots["city"] assert isinstance(slot, ResolvedEntity) assert slot.value == "tokyo" def test_stadt_slot_bekannter_wert(self, entity_matcher): """Bekannter Stadtwert sollte gematcht werden.""" entity_matcher.compile_intents([{ "name": "current_weather", "patterns": ["wetter [in] {city:stadt}"], }]) result = entity_matcher.match("Wetter in Berlin") assert result is not None assert "city" in result.slots def test_dauer_slot_match(self, entity_matcher): """Pattern mit {duration:dauer} sollte '5 minuten' matchen.""" entity_matcher.compile_intents([{ "name": "set_timer", "patterns": ["timer {duration:dauer}"], }]) result = entity_matcher.match("Timer 5 Minuten") assert result is not None assert "duration" in result.slots slot = result.slots["duration"] assert isinstance(slot, ResolvedEntity) assert slot.value == 300 def test_slot_ohne_entity_typ(self, entity_matcher): """Slot ohne Entity-Typ sollte wie bisher als String funktionieren.""" entity_matcher.compile_intents([{ "name": "volume_set", "patterns": ["lautstaerke [auf] {level}"], }]) result = entity_matcher.match("Lautstärke auf 50") assert result is not None assert "level" in result.slots assert isinstance(result.slots["level"], str) def test_pattern_mit_entity_typ_parse(self, entity_matcher): """Slot mit Entity-Typ sollte korrekt geparst werden.""" from trixy_core.nlp.keyword_matcher import PatternToken tokens = entity_matcher._parse_pattern("{date:datum}") assert len(tokens) == 1 assert tokens[0].kind == "slot" assert tokens[0].slot_name == "date" assert tokens[0].entity_type == "datum" def test_dauer_slot_strikt_ablehnung(self, entity_matcher): """Pattern mit {duration:dauer} sollte 'xyz' ablehnen.""" entity_matcher.compile_intents([{ "name": "set_timer", "patterns": ["timer {duration:dauer}"], }]) result = entity_matcher.match("Timer xyz") assert result is None def test_wetter_morgen_resolved_date(self, entity_matcher): """'Wetter morgen' → ResolvedEntity mit date.today() + 1.""" entity_matcher.compile_intents([{ "name": "weather_forecast", "patterns": ["wetter {date:datum}"], }]) result = entity_matcher.match("Wetter morgen") assert result is not None assert result.intent == "weather_forecast" slot = result.slots["date"] assert isinstance(slot, ResolvedEntity) tomorrow = date.today() + timedelta(days=1) assert slot.value == tomorrow, ( f"'morgen' sollte {tomorrow.isoformat()} sein, " f"aber ist {slot.value}" ) def test_wetter_montag_resolved_date(self, entity_matcher): """'Wetter Montag' → ResolvedEntity mit naechstem Montag.""" entity_matcher.compile_intents([{ "name": "weather_forecast", "patterns": ["wetter {date:datum}"], }]) result = entity_matcher.match("Wetter Montag") assert result is not None slot = result.slots["date"] assert isinstance(slot, ResolvedEntity) assert slot.value.weekday() == 0 # Montag assert slot.value > date.today() def test_wie_wird_am_montag_das_wetter(self, entity_matcher): """'Wie wird am Montag das Wetter' → weather_forecast mit Montag.""" entity_matcher.compile_intents([{ "name": "weather_forecast", "patterns": [ "wie wird {das|} [am] {date:datum} [das] wetter", "wie wird [das] wetter [am] {date:datum}", ], }, { "name": "current_weather", "patterns": [ "(wie ist|wie wird) [denn] {das|} wetter [draussen|gerade|heute]", ], }]) result = entity_matcher.match("Wie wird am Montag das Wetter") assert result is not None assert result.intent == "weather_forecast" slot = result.slots["date"] assert isinstance(slot, ResolvedEntity) assert slot.value.weekday() == 0 # Montag assert slot.value > date.today() # ============================================================================ # Hilfsfunktionen fuer Tests # ============================================================================ def _get_month_name(month: int) -> str: """Gibt den deutschen Monatsnamen (lowercase) fuer Tests zurueck.""" names = [ "", "januar", "februar", "maerz", "april", "mai", "juni", "juli", "august", "september", "oktober", "november", "dezember", ] return names[month]