# -*- coding: utf-8 -*- """ Tests fuer den EPGCache mit JSON-Persistenz und In-Memory-Indizes. Testet Laden/Speichern, Programm-Updates, Pruning, Abfragen nach Sender/Titel/Tag/Genre sowie Enrichment-Daten. """ from __future__ import annotations import json from datetime import date, datetime, timedelta, timezone from pathlib import Path import pytest from plugins.tv_program.epg.cache import EPGCache # ============================================================================ # Hilfsfunktionen fuer Testdaten # ============================================================================ def _jetzt() -> datetime: """Gibt die aktuelle Zeit mit Zeitzone zurueck.""" return datetime.now(tz=timezone.utc) def _iso(dt: datetime) -> str: """Konvertiert ein datetime-Objekt in ISO-Format.""" return dt.isoformat() def _programm( channel: str, title: str, start: datetime, dauer_min: int = 60, genre: list[str] | None = None, ) -> dict: """Erstellt ein Test-Programm-Dict.""" end = start + timedelta(minutes=dauer_min) prog: dict = { "ch": channel, "t": title, "s": _iso(start), "e": _iso(end), } if genre: prog["g"] = genre return prog def _programm_liste_heute(channel: str = "ard") -> list[dict]: """Erstellt eine Tages-Programmliste mit mehreren Sendungen.""" heute = _jetzt().replace(hour=0, minute=0, second=0, microsecond=0) return [ _programm(channel, "Morgenmagazin", heute.replace(hour=6), 120), _programm(channel, "Tagesschau", heute.replace(hour=12), 30), _programm(channel, "Mittagsmagazin", heute.replace(hour=12, minute=30), 60), _programm(channel, "Tagesschau", heute.replace(hour=20), 15), _programm( channel, "Tatort", heute.replace(hour=20, minute=15), 90, genre=["Krimi", "Spielfilm"], ), _programm(channel, "Tagesthemen", heute.replace(hour=21, minute=45), 30), ] # ============================================================================ # EPGCache - JSON I/O # ============================================================================ class TestEPGCacheLadenSpeichern: """Tests fuer die JSON-Persistenz des Caches.""" def test_load_fehlende_datei_gibt_false(self, tmp_path: Path) -> None: """Laden ohne vorhandene Datei gibt False zurueck.""" cache = EPGCache(tmp_path) assert cache.load() is False def test_load_fehlende_datei_leere_daten(self, tmp_path: Path) -> None: """Nach fehlgeschlagenem Laden sind die Daten leer.""" cache = EPGCache(tmp_path) cache.load() stats = cache.get_stats() assert stats["program_count"] == 0 def test_save_und_load_roundtrip(self, tmp_path: Path) -> None: """Gespeicherte Daten koennen wieder geladen werden.""" cache = EPGCache(tmp_path) programme = _programm_liste_heute() cache.update_programs(programme) cache.save() # Neuen Cache aus gleicher Datei laden cache2 = EPGCache(tmp_path) assert cache2.load() is True stats = cache2.get_stats() assert stats["program_count"] == len(programme) def test_save_erzeugt_json_datei(self, tmp_path: Path) -> None: """Nach dem Speichern existiert die JSON-Datei.""" cache = EPGCache(tmp_path) cache.save() assert (tmp_path / EPGCache.FILE_NAME).exists() def test_load_korrupte_datei_gibt_false(self, tmp_path: Path) -> None: """Beschaedigte JSON-Datei wird toleriert und gibt False zurueck.""" datei = tmp_path / EPGCache.FILE_NAME datei.write_text("{kaputt: invalid json!!!", encoding="utf-8") cache = EPGCache(tmp_path) assert cache.load() is False def test_load_korrupte_datei_leere_daten(self, tmp_path: Path) -> None: """Nach Laden einer korrupten Datei sind die Daten leer.""" datei = tmp_path / EPGCache.FILE_NAME datei.write_text("nicht json", encoding="utf-8") cache = EPGCache(tmp_path) cache.load() assert cache.get_stats()["program_count"] == 0 # ============================================================================ # EPGCache - update_programs() # ============================================================================ class TestEPGCacheUpdatePrograms: """Tests fuer das Hinzufuegen und Aktualisieren von Programmen.""" def test_update_fuegt_neue_programme_hinzu(self, tmp_path: Path) -> None: """Neue Programme werden korrekt hinzugefuegt.""" cache = EPGCache(tmp_path) programme = _programm_liste_heute() count = cache.update_programs(programme) assert count == len(programme) def test_update_gibt_neue_anzahl_zurueck(self, tmp_path: Path) -> None: """Rueckgabewert ist die Anzahl der neuen Programme.""" cache = EPGCache(tmp_path) programme = _programm_liste_heute() count = cache.update_programs(programme) assert count == 6 def test_update_deduplizierung_gleiche_id(self, tmp_path: Path) -> None: """Bereits vorhandene Programme (gleiche ID) werden nicht dupliziert.""" cache = EPGCache(tmp_path) programme = _programm_liste_heute() # Erstes Einfuegen cache.update_programs(programme) # Gleiches nochmal einfuegen count = cache.update_programs(programme) assert count == 0 # Keine neuen def test_update_stats_korrekt_nach_einfuegen(self, tmp_path: Path) -> None: """Statistiken stimmen nach dem Einfuegen.""" cache = EPGCache(tmp_path) programme = _programm_liste_heute() cache.update_programs(programme) stats = cache.get_stats() assert stats["program_count"] == len(programme) assert stats["channel_count"] >= 1 def test_update_leere_liste(self, tmp_path: Path) -> None: """Leere Programmliste verursacht keinen Fehler.""" cache = EPGCache(tmp_path) count = cache.update_programs([]) assert count == 0 # ============================================================================ # EPGCache - prune_old() # ============================================================================ class TestEPGCachePruneOld: """Tests fuer das Entfernen alter Sendungen.""" def test_prune_entfernt_alte_sendungen(self, tmp_path: Path) -> None: """Sendungen aelter als retention_days werden entfernt.""" cache = EPGCache(tmp_path) jetzt = _jetzt() alte_sendung = _programm("ard", "Alte Show", jetzt - timedelta(days=30)) cache.update_programs([alte_sendung]) removed = cache.prune_old(retention_days=14) assert removed == 1 assert cache.get_stats()["program_count"] == 0 def test_prune_behaelt_aktuelle_sendungen(self, tmp_path: Path) -> None: """Aktuelle Sendungen bleiben erhalten.""" cache = EPGCache(tmp_path) jetzt = _jetzt() aktuelle_sendung = _programm("ard", "Aktuelle Show", jetzt) cache.update_programs([aktuelle_sendung]) removed = cache.prune_old(retention_days=14) assert removed == 0 assert cache.get_stats()["program_count"] == 1 def test_prune_gemischt_alt_und_neu(self, tmp_path: Path) -> None: """Nur alte Sendungen werden entfernt, neue bleiben.""" cache = EPGCache(tmp_path) jetzt = _jetzt() programme = [ _programm("ard", "Alte Show", jetzt - timedelta(days=30)), _programm("ard", "Neue Show", jetzt + timedelta(hours=1)), ] cache.update_programs(programme) removed = cache.prune_old(retention_days=14) assert removed == 1 assert cache.get_stats()["program_count"] == 1 # ============================================================================ # EPGCache - get_program_at() # ============================================================================ class TestEPGCacheGetProgramAt: """Tests fuer die zeitpunktbasierte Sendungsabfrage.""" def test_programm_zur_genauen_startzeit(self, tmp_path: Path) -> None: """Sendung wird zur exakten Startzeit gefunden.""" cache = EPGCache(tmp_path) jetzt = _jetzt() start = jetzt.replace(hour=20, minute=15, second=0, microsecond=0) sendung = _programm("ard", "Tatort", start, 90) cache.update_programs([sendung]) ergebnis = cache.get_program_at("ard", start) assert ergebnis is not None assert ergebnis["t"] == "Tatort" def test_programm_waehrend_ausstrahlung(self, tmp_path: Path) -> None: """Sendung wird waehrend der Ausstrahlung gefunden.""" cache = EPGCache(tmp_path) jetzt = _jetzt() start = jetzt.replace(hour=20, minute=15, second=0, microsecond=0) sendung = _programm("ard", "Tatort", start, 90) cache.update_programs([sendung]) # 30 Minuten nach Start abfrage_zeit = start + timedelta(minutes=30) ergebnis = cache.get_program_at("ard", abfrage_zeit) assert ergebnis is not None assert ergebnis["t"] == "Tatort" def test_programm_nach_ende_none(self, tmp_path: Path) -> None: """Nach Sendungsende wird None zurueckgegeben.""" cache = EPGCache(tmp_path) jetzt = _jetzt() start = jetzt.replace(hour=20, minute=15, second=0, microsecond=0) sendung = _programm("ard", "Tatort", start, 90) cache.update_programs([sendung]) # 2 Stunden nach Start (Sendung ist 90 Min) abfrage_zeit = start + timedelta(hours=2) ergebnis = cache.get_program_at("ard", abfrage_zeit) assert ergebnis is None def test_programm_falscher_sender_none(self, tmp_path: Path) -> None: """Falscher Sender gibt None zurueck.""" cache = EPGCache(tmp_path) jetzt = _jetzt() start = jetzt.replace(hour=20, minute=15, second=0, microsecond=0) sendung = _programm("ard", "Tatort", start, 90) cache.update_programs([sendung]) ergebnis = cache.get_program_at("zdf", start) assert ergebnis is None def test_programm_leerer_sender_none(self, tmp_path: Path) -> None: """Sender ohne Programm gibt None zurueck.""" cache = EPGCache(tmp_path) ergebnis = cache.get_program_at("ard", _jetzt()) assert ergebnis is None # ============================================================================ # EPGCache - get_primetime() # ============================================================================ class TestEPGCacheGetPrimetime: """Tests fuer die Primetime-Abfrage (20:15).""" def test_primetime_findet_2015_sendung(self, tmp_path: Path) -> None: """Sendung um 20:15 wird als Primetime erkannt.""" cache = EPGCache(tmp_path) heute = date.today() start = datetime(heute.year, heute.month, heute.day, 20, 15, tzinfo=timezone.utc) sendung = _programm("ard", "Tatort", start, 90) cache.update_programs([sendung]) ergebnis = cache.get_primetime("ard", heute) assert ergebnis is not None assert ergebnis["t"] == "Tatort" def test_primetime_kein_programm_none(self, tmp_path: Path) -> None: """Ohne Programm um 20:15 wird None zurueckgegeben.""" cache = EPGCache(tmp_path) heute = date.today() # Sendung um 21:00, nicht 20:15 start = datetime(heute.year, heute.month, heute.day, 21, 0, tzinfo=timezone.utc) sendung = _programm("ard", "Spaetsendung", start, 60) cache.update_programs([sendung]) ergebnis = cache.get_primetime("ard", heute) assert ergebnis is None # ============================================================================ # EPGCache - search_title() # ============================================================================ class TestEPGCacheSearchTitle: """Tests fuer die Titelsuche.""" def test_suche_exakter_titel(self, tmp_path: Path) -> None: """Exakter Titel wird gefunden.""" cache = EPGCache(tmp_path) programme = _programm_liste_heute() cache.update_programs(programme) ergebnisse = cache.search_title("Tatort") assert len(ergebnisse) >= 1 assert any(p["t"] == "Tatort" for p in ergebnisse) def test_suche_teilstring(self, tmp_path: Path) -> None: """Teilstring-Suche findet passende Titel.""" cache = EPGCache(tmp_path) programme = _programm_liste_heute() cache.update_programs(programme) ergebnisse = cache.search_title("Tagesschau") assert len(ergebnisse) >= 1 def test_suche_kein_treffer_leere_liste(self, tmp_path: Path) -> None: """Ohne Treffer wird eine leere Liste zurueckgegeben.""" cache = EPGCache(tmp_path) programme = _programm_liste_heute() cache.update_programs(programme) ergebnisse = cache.search_title("Nichtexistierender Titel XYZ") assert ergebnisse == [] def test_suche_leerer_query_leere_liste(self, tmp_path: Path) -> None: """Leerer Suchbegriff gibt leere Liste zurueck.""" cache = EPGCache(tmp_path) ergebnisse = cache.search_title("") assert ergebnisse == [] def test_suche_zukunft_zuerst(self, tmp_path: Path) -> None: """Zukuenftige Sendungen werden vor vergangenen sortiert.""" cache = EPGCache(tmp_path) jetzt = _jetzt() programme = [ _programm("ard", "Tagesschau", jetzt - timedelta(hours=5)), _programm("ard", "Tagesschau", jetzt + timedelta(hours=5)), ] cache.update_programs(programme) ergebnisse = cache.search_title("Tagesschau") assert len(ergebnisse) == 2 # Erstes Ergebnis soll in der Zukunft liegen assert ergebnisse[0]["s"] > _iso(jetzt) def test_suche_limit_begrenzt_ergebnisse(self, tmp_path: Path) -> None: """Der Limit-Parameter begrenzt die Ergebnis-Anzahl.""" cache = EPGCache(tmp_path) jetzt = _jetzt() programme = [ _programm("ard", "Tagesschau", jetzt + timedelta(hours=i)) for i in range(20) ] cache.update_programs(programme) ergebnisse = cache.search_title("Tagesschau", limit=5) assert len(ergebnisse) <= 5 # ============================================================================ # EPGCache - search_by_tag() # ============================================================================ class TestEPGCacheSearchByTag: """Tests fuer die Tag-basierte Suche ueber Enrichment-Daten.""" def test_suche_nach_tag_findet_sendungen(self, tmp_path: Path) -> None: """Sendungen werden ueber Enrichment-Tags gefunden.""" cache = EPGCache(tmp_path) jetzt = _jetzt() sendung = _programm("ard", "Tatort", jetzt, 90) cache.update_programs([sendung]) # Enrichment mit Tags hinzufuegen series_id = cache._generate_series_id("Tatort") cache.update_enrichment(series_id, {"tags": ["Krimi", "Spannung"]}) ergebnisse = cache.search_by_tag("Krimi") assert len(ergebnisse) >= 1 def test_suche_nach_tag_gross_kleinschreibung(self, tmp_path: Path) -> None: """Tag-Suche ist case-insensitive.""" cache = EPGCache(tmp_path) jetzt = _jetzt() sendung = _programm("ard", "Tatort", jetzt, 90) cache.update_programs([sendung]) series_id = cache._generate_series_id("Tatort") cache.update_enrichment(series_id, {"tags": ["Krimi"]}) ergebnisse = cache.search_by_tag("krimi") assert len(ergebnisse) >= 1 def test_suche_nach_tag_kein_treffer(self, tmp_path: Path) -> None: """Ohne passenden Tag wird eine leere Liste zurueckgegeben.""" cache = EPGCache(tmp_path) ergebnisse = cache.search_by_tag("NichtExistierenderTag") assert ergebnisse == [] # ============================================================================ # EPGCache - get_next_airing() # ============================================================================ class TestEPGCacheGetNextAiring: """Tests fuer die naechste Ausstrahlung einer Serie.""" def test_naechste_ausstrahlung_in_zukunft(self, tmp_path: Path) -> None: """Findet die naechste zukuenftige Ausstrahlung.""" cache = EPGCache(tmp_path) jetzt = _jetzt() programme = [ _programm("ard", "Tatort", jetzt - timedelta(days=7)), _programm("ard", "Tatort", jetzt + timedelta(days=7)), ] cache.update_programs(programme) series_id = cache._generate_series_id("Tatort") ergebnis = cache.get_next_airing(series_id) assert ergebnis is not None assert ergebnis["s"] > _iso(jetzt) def test_naechste_ausstrahlung_keine_zukunft_none(self, tmp_path: Path) -> None: """Ohne zukuenftige Ausstrahlung wird None zurueckgegeben.""" cache = EPGCache(tmp_path) jetzt = _jetzt() programme = [ _programm("ard", "Tatort", jetzt - timedelta(days=14)), _programm("ard", "Tatort", jetzt - timedelta(days=7)), ] cache.update_programs(programme) series_id = cache._generate_series_id("Tatort") ergebnis = cache.get_next_airing(series_id) assert ergebnis is None def test_naechste_ausstrahlung_unbekannte_serie_none(self, tmp_path: Path) -> None: """Unbekannte Serien-ID gibt None zurueck.""" cache = EPGCache(tmp_path) ergebnis = cache.get_next_airing("unbekannte-serie") assert ergebnis is None # ============================================================================ # EPGCache - Streaming und Enrichment # ============================================================================ class TestEPGCacheStreamingUndEnrichment: """Tests fuer Streaming-Info und Enrichment-Abfragen.""" def test_streaming_info_aus_enrichment(self, tmp_path: Path) -> None: """Streaming-Anbieter werden aus Enrichment-Daten zurueckgegeben.""" cache = EPGCache(tmp_path) cache.update_enrichment("tatort", { "streaming": ["ARD Mediathek", "Netflix"], "tags": [], }) info = cache.get_streaming_info("tatort") assert "ARD Mediathek" in info assert "Netflix" in info def test_streaming_info_ohne_enrichment_leer(self, tmp_path: Path) -> None: """Ohne Enrichment-Daten wird eine leere Liste zurueckgegeben.""" cache = EPGCache(tmp_path) info = cache.get_streaming_info("unbekannt") assert info == [] def test_get_enrichment_vorhanden(self, tmp_path: Path) -> None: """Enrichment-Daten werden korrekt zurueckgegeben.""" cache = EPGCache(tmp_path) daten = {"tags": ["Krimi"], "streaming": ["Netflix"], "beschreibung": "Test"} cache.update_enrichment("tatort", daten) ergebnis = cache.get_enrichment("tatort") assert ergebnis is not None assert ergebnis["tags"] == ["Krimi"] assert ergebnis["beschreibung"] == "Test" def test_get_enrichment_nicht_vorhanden_none(self, tmp_path: Path) -> None: """Fehlende Enrichment-Daten geben None zurueck.""" cache = EPGCache(tmp_path) ergebnis = cache.get_enrichment("gibts_nicht") assert ergebnis is None def test_update_enrichment_speichert_und_liest(self, tmp_path: Path) -> None: """Enrichment-Daten werden gespeichert und koennen abgefragt werden.""" cache = EPGCache(tmp_path) cache.update_enrichment("naruto", {"tags": ["Anime"], "streaming": ["Crunchyroll"]}) ergebnis = cache.get_enrichment("naruto") assert ergebnis["tags"] == ["Anime"] assert ergebnis["streaming"] == ["Crunchyroll"] def test_enrichment_roundtrip_save_load(self, tmp_path: Path) -> None: """Enrichment-Daten ueberstehen save/load Roundtrip.""" cache = EPGCache(tmp_path) cache.update_enrichment("tatort", {"tags": ["Krimi"], "streaming": ["ARD"]}) cache.save() cache2 = EPGCache(tmp_path) cache2.load() assert cache2.get_enrichment("tatort")["tags"] == ["Krimi"] # ============================================================================ # EPGCache - _generate_series_id() # ============================================================================ class TestEPGCacheGenerateSeriesId: """Tests fuer die Serien-ID-Generierung.""" def test_slug_naruto_shippuden(self) -> None: """'Naruto Shippuden' wird zu 'naruto-shippuden'.""" assert EPGCache._generate_series_id("Naruto Shippuden") == "naruto-shippuden" def test_slug_sonderzeichen_entfernt(self) -> None: """Sonderzeichen werden durch Bindestriche ersetzt.""" assert EPGCache._generate_series_id("SOKO Leipzig!") == "soko-leipzig" def test_slug_leer(self) -> None: """Leerer Titel ergibt leeren String.""" assert EPGCache._generate_series_id("") == "" def test_slug_whitespace_gestrippt(self) -> None: """Fuehrende und abschliessende Leerzeichen werden entfernt.""" ergebnis = EPGCache._generate_series_id(" Tatort ") assert ergebnis == "tatort" assert not ergebnis.startswith("-") assert not ergebnis.endswith("-") # ============================================================================ # EPGCache - _normalize_title() # ============================================================================ class TestEPGCacheNormalizeTitle: """Tests fuer die Titel-Normalisierung.""" def test_normalize_lowercase(self) -> None: """Titel wird zu Kleinbuchstaben konvertiert.""" assert EPGCache._normalize_title("TATORT") == "tatort" def test_normalize_artikel_entfernt(self) -> None: """Fuehrende Artikel (der, die, das) werden entfernt.""" assert EPGCache._normalize_title("Der Tatort") == "tatort" assert EPGCache._normalize_title("Die Sendung") == "sendung" assert EPGCache._normalize_title("Das Erste") == "erste" def test_normalize_englische_artikel(self) -> None: """Englische Artikel (the, a, an) werden ebenfalls entfernt.""" assert EPGCache._normalize_title("The Walking Dead") == "walking dead" # ============================================================================ # EPGCache - get_stats() # ============================================================================ class TestEPGCacheGetStats: """Tests fuer die Statistik-Abfrage.""" def test_stats_leerer_cache(self, tmp_path: Path) -> None: """Leerer Cache hat ueberall Nullwerte.""" cache = EPGCache(tmp_path) stats = cache.get_stats() assert stats["program_count"] == 0 assert stats["channel_count"] == 0 assert stats["enriched_count"] == 0 def test_stats_korrekte_programmanzahl(self, tmp_path: Path) -> None: """Statistik zaehlt Programme korrekt.""" cache = EPGCache(tmp_path) programme = _programm_liste_heute() cache.update_programs(programme) stats = cache.get_stats() assert stats["program_count"] == len(programme) def test_stats_korrekte_senderanzahl(self, tmp_path: Path) -> None: """Statistik zaehlt Sender korrekt.""" cache = EPGCache(tmp_path) jetzt = _jetzt() programme = [ _programm("ard", "Show A", jetzt), _programm("zdf", "Show B", jetzt + timedelta(hours=1)), ] cache.update_programs(programme) stats = cache.get_stats() assert stats["channel_count"] == 2 def test_stats_enrichment_anzahl(self, tmp_path: Path) -> None: """Statistik zaehlt Enrichments korrekt.""" cache = EPGCache(tmp_path) cache.update_enrichment("tatort", {"tags": ["Krimi"]}) cache.update_enrichment("tagesschau", {"tags": ["Nachrichten"]}) stats = cache.get_stats() assert stats["enriched_count"] == 2 def test_stats_enthaelt_alle_felder(self, tmp_path: Path) -> None: """Stats-Dict enthaelt alle erwarteten Felder.""" cache = EPGCache(tmp_path) stats = cache.get_stats() erwartete_felder = { "program_count", "channel_count", "enriched_count", "reminder_count", "title_count", "series_count", "tag_count", } assert erwartete_felder.issubset(stats.keys())