test_cache.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646
  1. # -*- coding: utf-8 -*-
  2. """
  3. Tests fuer den EPGCache mit JSON-Persistenz und In-Memory-Indizes.
  4. Testet Laden/Speichern, Programm-Updates, Pruning, Abfragen nach
  5. Sender/Titel/Tag/Genre sowie Enrichment-Daten.
  6. """
  7. from __future__ import annotations
  8. import json
  9. from datetime import date, datetime, timedelta, timezone
  10. from pathlib import Path
  11. import pytest
  12. from plugins.tv_program.epg.cache import EPGCache
  13. # ============================================================================
  14. # Hilfsfunktionen fuer Testdaten
  15. # ============================================================================
  16. def _jetzt() -> datetime:
  17. """Gibt die aktuelle Zeit mit Zeitzone zurueck."""
  18. return datetime.now(tz=timezone.utc)
  19. def _iso(dt: datetime) -> str:
  20. """Konvertiert ein datetime-Objekt in ISO-Format."""
  21. return dt.isoformat()
  22. def _programm(
  23. channel: str,
  24. title: str,
  25. start: datetime,
  26. dauer_min: int = 60,
  27. genre: list[str] | None = None,
  28. ) -> dict:
  29. """Erstellt ein Test-Programm-Dict."""
  30. end = start + timedelta(minutes=dauer_min)
  31. prog: dict = {
  32. "ch": channel,
  33. "t": title,
  34. "s": _iso(start),
  35. "e": _iso(end),
  36. }
  37. if genre:
  38. prog["g"] = genre
  39. return prog
  40. def _programm_liste_heute(channel: str = "ard") -> list[dict]:
  41. """Erstellt eine Tages-Programmliste mit mehreren Sendungen."""
  42. heute = _jetzt().replace(hour=0, minute=0, second=0, microsecond=0)
  43. return [
  44. _programm(channel, "Morgenmagazin", heute.replace(hour=6), 120),
  45. _programm(channel, "Tagesschau", heute.replace(hour=12), 30),
  46. _programm(channel, "Mittagsmagazin", heute.replace(hour=12, minute=30), 60),
  47. _programm(channel, "Tagesschau", heute.replace(hour=20), 15),
  48. _programm(
  49. channel,
  50. "Tatort",
  51. heute.replace(hour=20, minute=15),
  52. 90,
  53. genre=["Krimi", "Spielfilm"],
  54. ),
  55. _programm(channel, "Tagesthemen", heute.replace(hour=21, minute=45), 30),
  56. ]
  57. # ============================================================================
  58. # EPGCache - JSON I/O
  59. # ============================================================================
  60. class TestEPGCacheLadenSpeichern:
  61. """Tests fuer die JSON-Persistenz des Caches."""
  62. def test_load_fehlende_datei_gibt_false(self, tmp_path: Path) -> None:
  63. """Laden ohne vorhandene Datei gibt False zurueck."""
  64. cache = EPGCache(tmp_path)
  65. assert cache.load() is False
  66. def test_load_fehlende_datei_leere_daten(self, tmp_path: Path) -> None:
  67. """Nach fehlgeschlagenem Laden sind die Daten leer."""
  68. cache = EPGCache(tmp_path)
  69. cache.load()
  70. stats = cache.get_stats()
  71. assert stats["program_count"] == 0
  72. def test_save_und_load_roundtrip(self, tmp_path: Path) -> None:
  73. """Gespeicherte Daten koennen wieder geladen werden."""
  74. cache = EPGCache(tmp_path)
  75. programme = _programm_liste_heute()
  76. cache.update_programs(programme)
  77. cache.save()
  78. # Neuen Cache aus gleicher Datei laden
  79. cache2 = EPGCache(tmp_path)
  80. assert cache2.load() is True
  81. stats = cache2.get_stats()
  82. assert stats["program_count"] == len(programme)
  83. def test_save_erzeugt_json_datei(self, tmp_path: Path) -> None:
  84. """Nach dem Speichern existiert die JSON-Datei."""
  85. cache = EPGCache(tmp_path)
  86. cache.save()
  87. assert (tmp_path / EPGCache.FILE_NAME).exists()
  88. def test_load_korrupte_datei_gibt_false(self, tmp_path: Path) -> None:
  89. """Beschaedigte JSON-Datei wird toleriert und gibt False zurueck."""
  90. datei = tmp_path / EPGCache.FILE_NAME
  91. datei.write_text("{kaputt: invalid json!!!", encoding="utf-8")
  92. cache = EPGCache(tmp_path)
  93. assert cache.load() is False
  94. def test_load_korrupte_datei_leere_daten(self, tmp_path: Path) -> None:
  95. """Nach Laden einer korrupten Datei sind die Daten leer."""
  96. datei = tmp_path / EPGCache.FILE_NAME
  97. datei.write_text("nicht json", encoding="utf-8")
  98. cache = EPGCache(tmp_path)
  99. cache.load()
  100. assert cache.get_stats()["program_count"] == 0
  101. # ============================================================================
  102. # EPGCache - update_programs()
  103. # ============================================================================
  104. class TestEPGCacheUpdatePrograms:
  105. """Tests fuer das Hinzufuegen und Aktualisieren von Programmen."""
  106. def test_update_fuegt_neue_programme_hinzu(self, tmp_path: Path) -> None:
  107. """Neue Programme werden korrekt hinzugefuegt."""
  108. cache = EPGCache(tmp_path)
  109. programme = _programm_liste_heute()
  110. count = cache.update_programs(programme)
  111. assert count == len(programme)
  112. def test_update_gibt_neue_anzahl_zurueck(self, tmp_path: Path) -> None:
  113. """Rueckgabewert ist die Anzahl der neuen Programme."""
  114. cache = EPGCache(tmp_path)
  115. programme = _programm_liste_heute()
  116. count = cache.update_programs(programme)
  117. assert count == 6
  118. def test_update_deduplizierung_gleiche_id(self, tmp_path: Path) -> None:
  119. """Bereits vorhandene Programme (gleiche ID) werden nicht dupliziert."""
  120. cache = EPGCache(tmp_path)
  121. programme = _programm_liste_heute()
  122. # Erstes Einfuegen
  123. cache.update_programs(programme)
  124. # Gleiches nochmal einfuegen
  125. count = cache.update_programs(programme)
  126. assert count == 0 # Keine neuen
  127. def test_update_stats_korrekt_nach_einfuegen(self, tmp_path: Path) -> None:
  128. """Statistiken stimmen nach dem Einfuegen."""
  129. cache = EPGCache(tmp_path)
  130. programme = _programm_liste_heute()
  131. cache.update_programs(programme)
  132. stats = cache.get_stats()
  133. assert stats["program_count"] == len(programme)
  134. assert stats["channel_count"] >= 1
  135. def test_update_leere_liste(self, tmp_path: Path) -> None:
  136. """Leere Programmliste verursacht keinen Fehler."""
  137. cache = EPGCache(tmp_path)
  138. count = cache.update_programs([])
  139. assert count == 0
  140. # ============================================================================
  141. # EPGCache - prune_old()
  142. # ============================================================================
  143. class TestEPGCachePruneOld:
  144. """Tests fuer das Entfernen alter Sendungen."""
  145. def test_prune_entfernt_alte_sendungen(self, tmp_path: Path) -> None:
  146. """Sendungen aelter als retention_days werden entfernt."""
  147. cache = EPGCache(tmp_path)
  148. jetzt = _jetzt()
  149. alte_sendung = _programm("ard", "Alte Show", jetzt - timedelta(days=30))
  150. cache.update_programs([alte_sendung])
  151. removed = cache.prune_old(retention_days=14)
  152. assert removed == 1
  153. assert cache.get_stats()["program_count"] == 0
  154. def test_prune_behaelt_aktuelle_sendungen(self, tmp_path: Path) -> None:
  155. """Aktuelle Sendungen bleiben erhalten."""
  156. cache = EPGCache(tmp_path)
  157. jetzt = _jetzt()
  158. aktuelle_sendung = _programm("ard", "Aktuelle Show", jetzt)
  159. cache.update_programs([aktuelle_sendung])
  160. removed = cache.prune_old(retention_days=14)
  161. assert removed == 0
  162. assert cache.get_stats()["program_count"] == 1
  163. def test_prune_gemischt_alt_und_neu(self, tmp_path: Path) -> None:
  164. """Nur alte Sendungen werden entfernt, neue bleiben."""
  165. cache = EPGCache(tmp_path)
  166. jetzt = _jetzt()
  167. programme = [
  168. _programm("ard", "Alte Show", jetzt - timedelta(days=30)),
  169. _programm("ard", "Neue Show", jetzt + timedelta(hours=1)),
  170. ]
  171. cache.update_programs(programme)
  172. removed = cache.prune_old(retention_days=14)
  173. assert removed == 1
  174. assert cache.get_stats()["program_count"] == 1
  175. # ============================================================================
  176. # EPGCache - get_program_at()
  177. # ============================================================================
  178. class TestEPGCacheGetProgramAt:
  179. """Tests fuer die zeitpunktbasierte Sendungsabfrage."""
  180. def test_programm_zur_genauen_startzeit(self, tmp_path: Path) -> None:
  181. """Sendung wird zur exakten Startzeit gefunden."""
  182. cache = EPGCache(tmp_path)
  183. jetzt = _jetzt()
  184. start = jetzt.replace(hour=20, minute=15, second=0, microsecond=0)
  185. sendung = _programm("ard", "Tatort", start, 90)
  186. cache.update_programs([sendung])
  187. ergebnis = cache.get_program_at("ard", start)
  188. assert ergebnis is not None
  189. assert ergebnis["t"] == "Tatort"
  190. def test_programm_waehrend_ausstrahlung(self, tmp_path: Path) -> None:
  191. """Sendung wird waehrend der Ausstrahlung gefunden."""
  192. cache = EPGCache(tmp_path)
  193. jetzt = _jetzt()
  194. start = jetzt.replace(hour=20, minute=15, second=0, microsecond=0)
  195. sendung = _programm("ard", "Tatort", start, 90)
  196. cache.update_programs([sendung])
  197. # 30 Minuten nach Start
  198. abfrage_zeit = start + timedelta(minutes=30)
  199. ergebnis = cache.get_program_at("ard", abfrage_zeit)
  200. assert ergebnis is not None
  201. assert ergebnis["t"] == "Tatort"
  202. def test_programm_nach_ende_none(self, tmp_path: Path) -> None:
  203. """Nach Sendungsende wird None zurueckgegeben."""
  204. cache = EPGCache(tmp_path)
  205. jetzt = _jetzt()
  206. start = jetzt.replace(hour=20, minute=15, second=0, microsecond=0)
  207. sendung = _programm("ard", "Tatort", start, 90)
  208. cache.update_programs([sendung])
  209. # 2 Stunden nach Start (Sendung ist 90 Min)
  210. abfrage_zeit = start + timedelta(hours=2)
  211. ergebnis = cache.get_program_at("ard", abfrage_zeit)
  212. assert ergebnis is None
  213. def test_programm_falscher_sender_none(self, tmp_path: Path) -> None:
  214. """Falscher Sender gibt None zurueck."""
  215. cache = EPGCache(tmp_path)
  216. jetzt = _jetzt()
  217. start = jetzt.replace(hour=20, minute=15, second=0, microsecond=0)
  218. sendung = _programm("ard", "Tatort", start, 90)
  219. cache.update_programs([sendung])
  220. ergebnis = cache.get_program_at("zdf", start)
  221. assert ergebnis is None
  222. def test_programm_leerer_sender_none(self, tmp_path: Path) -> None:
  223. """Sender ohne Programm gibt None zurueck."""
  224. cache = EPGCache(tmp_path)
  225. ergebnis = cache.get_program_at("ard", _jetzt())
  226. assert ergebnis is None
  227. # ============================================================================
  228. # EPGCache - get_primetime()
  229. # ============================================================================
  230. class TestEPGCacheGetPrimetime:
  231. """Tests fuer die Primetime-Abfrage (20:15)."""
  232. def test_primetime_findet_2015_sendung(self, tmp_path: Path) -> None:
  233. """Sendung um 20:15 wird als Primetime erkannt."""
  234. cache = EPGCache(tmp_path)
  235. heute = date.today()
  236. start = datetime(heute.year, heute.month, heute.day, 20, 15,
  237. tzinfo=timezone.utc)
  238. sendung = _programm("ard", "Tatort", start, 90)
  239. cache.update_programs([sendung])
  240. ergebnis = cache.get_primetime("ard", heute)
  241. assert ergebnis is not None
  242. assert ergebnis["t"] == "Tatort"
  243. def test_primetime_kein_programm_none(self, tmp_path: Path) -> None:
  244. """Ohne Programm um 20:15 wird None zurueckgegeben."""
  245. cache = EPGCache(tmp_path)
  246. heute = date.today()
  247. # Sendung um 21:00, nicht 20:15
  248. start = datetime(heute.year, heute.month, heute.day, 21, 0,
  249. tzinfo=timezone.utc)
  250. sendung = _programm("ard", "Spaetsendung", start, 60)
  251. cache.update_programs([sendung])
  252. ergebnis = cache.get_primetime("ard", heute)
  253. assert ergebnis is None
  254. # ============================================================================
  255. # EPGCache - search_title()
  256. # ============================================================================
  257. class TestEPGCacheSearchTitle:
  258. """Tests fuer die Titelsuche."""
  259. def test_suche_exakter_titel(self, tmp_path: Path) -> None:
  260. """Exakter Titel wird gefunden."""
  261. cache = EPGCache(tmp_path)
  262. programme = _programm_liste_heute()
  263. cache.update_programs(programme)
  264. ergebnisse = cache.search_title("Tatort")
  265. assert len(ergebnisse) >= 1
  266. assert any(p["t"] == "Tatort" for p in ergebnisse)
  267. def test_suche_teilstring(self, tmp_path: Path) -> None:
  268. """Teilstring-Suche findet passende Titel."""
  269. cache = EPGCache(tmp_path)
  270. programme = _programm_liste_heute()
  271. cache.update_programs(programme)
  272. ergebnisse = cache.search_title("Tagesschau")
  273. assert len(ergebnisse) >= 1
  274. def test_suche_kein_treffer_leere_liste(self, tmp_path: Path) -> None:
  275. """Ohne Treffer wird eine leere Liste zurueckgegeben."""
  276. cache = EPGCache(tmp_path)
  277. programme = _programm_liste_heute()
  278. cache.update_programs(programme)
  279. ergebnisse = cache.search_title("Nichtexistierender Titel XYZ")
  280. assert ergebnisse == []
  281. def test_suche_leerer_query_leere_liste(self, tmp_path: Path) -> None:
  282. """Leerer Suchbegriff gibt leere Liste zurueck."""
  283. cache = EPGCache(tmp_path)
  284. ergebnisse = cache.search_title("")
  285. assert ergebnisse == []
  286. def test_suche_zukunft_zuerst(self, tmp_path: Path) -> None:
  287. """Zukuenftige Sendungen werden vor vergangenen sortiert."""
  288. cache = EPGCache(tmp_path)
  289. jetzt = _jetzt()
  290. programme = [
  291. _programm("ard", "Tagesschau", jetzt - timedelta(hours=5)),
  292. _programm("ard", "Tagesschau", jetzt + timedelta(hours=5)),
  293. ]
  294. cache.update_programs(programme)
  295. ergebnisse = cache.search_title("Tagesschau")
  296. assert len(ergebnisse) == 2
  297. # Erstes Ergebnis soll in der Zukunft liegen
  298. assert ergebnisse[0]["s"] > _iso(jetzt)
  299. def test_suche_limit_begrenzt_ergebnisse(self, tmp_path: Path) -> None:
  300. """Der Limit-Parameter begrenzt die Ergebnis-Anzahl."""
  301. cache = EPGCache(tmp_path)
  302. jetzt = _jetzt()
  303. programme = [
  304. _programm("ard", "Tagesschau", jetzt + timedelta(hours=i))
  305. for i in range(20)
  306. ]
  307. cache.update_programs(programme)
  308. ergebnisse = cache.search_title("Tagesschau", limit=5)
  309. assert len(ergebnisse) <= 5
  310. # ============================================================================
  311. # EPGCache - search_by_tag()
  312. # ============================================================================
  313. class TestEPGCacheSearchByTag:
  314. """Tests fuer die Tag-basierte Suche ueber Enrichment-Daten."""
  315. def test_suche_nach_tag_findet_sendungen(self, tmp_path: Path) -> None:
  316. """Sendungen werden ueber Enrichment-Tags gefunden."""
  317. cache = EPGCache(tmp_path)
  318. jetzt = _jetzt()
  319. sendung = _programm("ard", "Tatort", jetzt, 90)
  320. cache.update_programs([sendung])
  321. # Enrichment mit Tags hinzufuegen
  322. series_id = cache._generate_series_id("Tatort")
  323. cache.update_enrichment(series_id, {"tags": ["Krimi", "Spannung"]})
  324. ergebnisse = cache.search_by_tag("Krimi")
  325. assert len(ergebnisse) >= 1
  326. def test_suche_nach_tag_gross_kleinschreibung(self, tmp_path: Path) -> None:
  327. """Tag-Suche ist case-insensitive."""
  328. cache = EPGCache(tmp_path)
  329. jetzt = _jetzt()
  330. sendung = _programm("ard", "Tatort", jetzt, 90)
  331. cache.update_programs([sendung])
  332. series_id = cache._generate_series_id("Tatort")
  333. cache.update_enrichment(series_id, {"tags": ["Krimi"]})
  334. ergebnisse = cache.search_by_tag("krimi")
  335. assert len(ergebnisse) >= 1
  336. def test_suche_nach_tag_kein_treffer(self, tmp_path: Path) -> None:
  337. """Ohne passenden Tag wird eine leere Liste zurueckgegeben."""
  338. cache = EPGCache(tmp_path)
  339. ergebnisse = cache.search_by_tag("NichtExistierenderTag")
  340. assert ergebnisse == []
  341. # ============================================================================
  342. # EPGCache - get_next_airing()
  343. # ============================================================================
  344. class TestEPGCacheGetNextAiring:
  345. """Tests fuer die naechste Ausstrahlung einer Serie."""
  346. def test_naechste_ausstrahlung_in_zukunft(self, tmp_path: Path) -> None:
  347. """Findet die naechste zukuenftige Ausstrahlung."""
  348. cache = EPGCache(tmp_path)
  349. jetzt = _jetzt()
  350. programme = [
  351. _programm("ard", "Tatort", jetzt - timedelta(days=7)),
  352. _programm("ard", "Tatort", jetzt + timedelta(days=7)),
  353. ]
  354. cache.update_programs(programme)
  355. series_id = cache._generate_series_id("Tatort")
  356. ergebnis = cache.get_next_airing(series_id)
  357. assert ergebnis is not None
  358. assert ergebnis["s"] > _iso(jetzt)
  359. def test_naechste_ausstrahlung_keine_zukunft_none(self, tmp_path: Path) -> None:
  360. """Ohne zukuenftige Ausstrahlung wird None zurueckgegeben."""
  361. cache = EPGCache(tmp_path)
  362. jetzt = _jetzt()
  363. programme = [
  364. _programm("ard", "Tatort", jetzt - timedelta(days=14)),
  365. _programm("ard", "Tatort", jetzt - timedelta(days=7)),
  366. ]
  367. cache.update_programs(programme)
  368. series_id = cache._generate_series_id("Tatort")
  369. ergebnis = cache.get_next_airing(series_id)
  370. assert ergebnis is None
  371. def test_naechste_ausstrahlung_unbekannte_serie_none(self, tmp_path: Path) -> None:
  372. """Unbekannte Serien-ID gibt None zurueck."""
  373. cache = EPGCache(tmp_path)
  374. ergebnis = cache.get_next_airing("unbekannte-serie")
  375. assert ergebnis is None
  376. # ============================================================================
  377. # EPGCache - Streaming und Enrichment
  378. # ============================================================================
  379. class TestEPGCacheStreamingUndEnrichment:
  380. """Tests fuer Streaming-Info und Enrichment-Abfragen."""
  381. def test_streaming_info_aus_enrichment(self, tmp_path: Path) -> None:
  382. """Streaming-Anbieter werden aus Enrichment-Daten zurueckgegeben."""
  383. cache = EPGCache(tmp_path)
  384. cache.update_enrichment("tatort", {
  385. "streaming": ["ARD Mediathek", "Netflix"],
  386. "tags": [],
  387. })
  388. info = cache.get_streaming_info("tatort")
  389. assert "ARD Mediathek" in info
  390. assert "Netflix" in info
  391. def test_streaming_info_ohne_enrichment_leer(self, tmp_path: Path) -> None:
  392. """Ohne Enrichment-Daten wird eine leere Liste zurueckgegeben."""
  393. cache = EPGCache(tmp_path)
  394. info = cache.get_streaming_info("unbekannt")
  395. assert info == []
  396. def test_get_enrichment_vorhanden(self, tmp_path: Path) -> None:
  397. """Enrichment-Daten werden korrekt zurueckgegeben."""
  398. cache = EPGCache(tmp_path)
  399. daten = {"tags": ["Krimi"], "streaming": ["Netflix"], "beschreibung": "Test"}
  400. cache.update_enrichment("tatort", daten)
  401. ergebnis = cache.get_enrichment("tatort")
  402. assert ergebnis is not None
  403. assert ergebnis["tags"] == ["Krimi"]
  404. assert ergebnis["beschreibung"] == "Test"
  405. def test_get_enrichment_nicht_vorhanden_none(self, tmp_path: Path) -> None:
  406. """Fehlende Enrichment-Daten geben None zurueck."""
  407. cache = EPGCache(tmp_path)
  408. ergebnis = cache.get_enrichment("gibts_nicht")
  409. assert ergebnis is None
  410. def test_update_enrichment_speichert_und_liest(self, tmp_path: Path) -> None:
  411. """Enrichment-Daten werden gespeichert und koennen abgefragt werden."""
  412. cache = EPGCache(tmp_path)
  413. cache.update_enrichment("naruto", {"tags": ["Anime"], "streaming": ["Crunchyroll"]})
  414. ergebnis = cache.get_enrichment("naruto")
  415. assert ergebnis["tags"] == ["Anime"]
  416. assert ergebnis["streaming"] == ["Crunchyroll"]
  417. def test_enrichment_roundtrip_save_load(self, tmp_path: Path) -> None:
  418. """Enrichment-Daten ueberstehen save/load Roundtrip."""
  419. cache = EPGCache(tmp_path)
  420. cache.update_enrichment("tatort", {"tags": ["Krimi"], "streaming": ["ARD"]})
  421. cache.save()
  422. cache2 = EPGCache(tmp_path)
  423. cache2.load()
  424. assert cache2.get_enrichment("tatort")["tags"] == ["Krimi"]
  425. # ============================================================================
  426. # EPGCache - _generate_series_id()
  427. # ============================================================================
  428. class TestEPGCacheGenerateSeriesId:
  429. """Tests fuer die Serien-ID-Generierung."""
  430. def test_slug_naruto_shippuden(self) -> None:
  431. """'Naruto Shippuden' wird zu 'naruto-shippuden'."""
  432. assert EPGCache._generate_series_id("Naruto Shippuden") == "naruto-shippuden"
  433. def test_slug_sonderzeichen_entfernt(self) -> None:
  434. """Sonderzeichen werden durch Bindestriche ersetzt."""
  435. assert EPGCache._generate_series_id("SOKO Leipzig!") == "soko-leipzig"
  436. def test_slug_leer(self) -> None:
  437. """Leerer Titel ergibt leeren String."""
  438. assert EPGCache._generate_series_id("") == ""
  439. def test_slug_whitespace_gestrippt(self) -> None:
  440. """Fuehrende und abschliessende Leerzeichen werden entfernt."""
  441. ergebnis = EPGCache._generate_series_id(" Tatort ")
  442. assert ergebnis == "tatort"
  443. assert not ergebnis.startswith("-")
  444. assert not ergebnis.endswith("-")
  445. # ============================================================================
  446. # EPGCache - _normalize_title()
  447. # ============================================================================
  448. class TestEPGCacheNormalizeTitle:
  449. """Tests fuer die Titel-Normalisierung."""
  450. def test_normalize_lowercase(self) -> None:
  451. """Titel wird zu Kleinbuchstaben konvertiert."""
  452. assert EPGCache._normalize_title("TATORT") == "tatort"
  453. def test_normalize_artikel_entfernt(self) -> None:
  454. """Fuehrende Artikel (der, die, das) werden entfernt."""
  455. assert EPGCache._normalize_title("Der Tatort") == "tatort"
  456. assert EPGCache._normalize_title("Die Sendung") == "sendung"
  457. assert EPGCache._normalize_title("Das Erste") == "erste"
  458. def test_normalize_englische_artikel(self) -> None:
  459. """Englische Artikel (the, a, an) werden ebenfalls entfernt."""
  460. assert EPGCache._normalize_title("The Walking Dead") == "walking dead"
  461. # ============================================================================
  462. # EPGCache - get_stats()
  463. # ============================================================================
  464. class TestEPGCacheGetStats:
  465. """Tests fuer die Statistik-Abfrage."""
  466. def test_stats_leerer_cache(self, tmp_path: Path) -> None:
  467. """Leerer Cache hat ueberall Nullwerte."""
  468. cache = EPGCache(tmp_path)
  469. stats = cache.get_stats()
  470. assert stats["program_count"] == 0
  471. assert stats["channel_count"] == 0
  472. assert stats["enriched_count"] == 0
  473. def test_stats_korrekte_programmanzahl(self, tmp_path: Path) -> None:
  474. """Statistik zaehlt Programme korrekt."""
  475. cache = EPGCache(tmp_path)
  476. programme = _programm_liste_heute()
  477. cache.update_programs(programme)
  478. stats = cache.get_stats()
  479. assert stats["program_count"] == len(programme)
  480. def test_stats_korrekte_senderanzahl(self, tmp_path: Path) -> None:
  481. """Statistik zaehlt Sender korrekt."""
  482. cache = EPGCache(tmp_path)
  483. jetzt = _jetzt()
  484. programme = [
  485. _programm("ard", "Show A", jetzt),
  486. _programm("zdf", "Show B", jetzt + timedelta(hours=1)),
  487. ]
  488. cache.update_programs(programme)
  489. stats = cache.get_stats()
  490. assert stats["channel_count"] == 2
  491. def test_stats_enrichment_anzahl(self, tmp_path: Path) -> None:
  492. """Statistik zaehlt Enrichments korrekt."""
  493. cache = EPGCache(tmp_path)
  494. cache.update_enrichment("tatort", {"tags": ["Krimi"]})
  495. cache.update_enrichment("tagesschau", {"tags": ["Nachrichten"]})
  496. stats = cache.get_stats()
  497. assert stats["enriched_count"] == 2
  498. def test_stats_enthaelt_alle_felder(self, tmp_path: Path) -> None:
  499. """Stats-Dict enthaelt alle erwarteten Felder."""
  500. cache = EPGCache(tmp_path)
  501. stats = cache.get_stats()
  502. erwartete_felder = {
  503. "program_count", "channel_count", "enriched_count",
  504. "reminder_count", "title_count", "series_count", "tag_count",
  505. }
  506. assert erwartete_felder.issubset(stats.keys())