| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568 |
- # -*- coding: utf-8 -*-
- """
- Tests fuer StreamingCache — JSON-Katalog-Cache mit In-Memory-Indizes.
- Deckt JSON I/O, Index-Aufbau, Suche, Filter (Sprache, Genre, Bewertung),
- Slug-Generierung und atomares Speichern ab.
- """
- from __future__ import annotations
- import json
- from pathlib import Path
- from unittest.mock import AsyncMock, patch
- import pytest
- from plugins.streaming.catalog.cache import StreamingCache
- # ---------------------------------------------------------------------------
- # Hilfsfunktionen und Testdaten
- # ---------------------------------------------------------------------------
- def _make_title(
- name: str,
- ty: str = "serie",
- genres: list[str] | None = None,
- rating: float = 7.0,
- year: int = 2020,
- services: dict | None = None,
- orig_lang: str = "ja",
- tmdb_id: int = 1,
- ) -> dict:
- """Erzeugt ein realistisches Title-Dict."""
- if genres is None:
- genres = ["Action"]
- if services is None:
- services = {
- "netflix": {"dub": ["de", "en"], "sub": ["de"]},
- }
- return {
- "t": name,
- "ty": ty,
- "g": genres,
- "d": f"Beschreibung fuer {name}",
- "yr": year,
- "rt": rating,
- "orig_lang": orig_lang,
- "on": services,
- "tmdb_id": tmdb_id,
- }
- NARUTO = _make_title(
- "Naruto Shippuden",
- genres=["Action", "Anime"],
- rating=8.1,
- year=2007,
- services={
- "crunchyroll": {"dub": ["ja"], "sub": ["de"]},
- "netflix": {"dub": ["de", "ja"], "sub": ["de"]},
- },
- tmdb_id=46559,
- )
- ONE_PIECE = _make_title(
- "One Piece",
- genres=["Action", "Anime"],
- rating=8.7,
- year=1999,
- services={
- "crunchyroll": {"dub": ["ja"], "sub": ["de"]},
- },
- tmdb_id=37854,
- )
- BREAKING_BAD = _make_title(
- "Breaking Bad",
- genres=["Drama", "Thriller"],
- rating=9.5,
- year=2008,
- services={
- "netflix": {"dub": ["de", "en"], "sub": ["de", "en"]},
- },
- orig_lang="en",
- tmdb_id=1396,
- )
- DARK = _make_title(
- "Dark",
- genres=["Drama", "Mystery"],
- rating=8.8,
- year=2017,
- services={
- "netflix": {"dub": ["de"], "sub": ["de", "en"]},
- },
- orig_lang="de",
- tmdb_id=70523,
- )
- INCEPTION = _make_title(
- "Inception",
- ty="film",
- genres=["Action", "Science Fiction"],
- rating=8.4,
- year=2010,
- services={
- "netflix": {"dub": ["de", "en"], "sub": ["de"]},
- "amazon_prime": {"dub": ["de", "en"], "sub": ["de"]},
- },
- orig_lang="en",
- tmdb_id=27205,
- )
- def _copy_title(title: dict) -> dict:
- """Tiefe Kopie eines Title-Dicts (on-Dict separat kopieren)."""
- import copy
- return copy.deepcopy(title)
- def _seed_cache(cache: StreamingCache) -> None:
- """Befuellt den Cache mit Testdaten und baut Indizes auf."""
- cache.update_title("naruto-shippuden", _copy_title(NARUTO))
- cache.update_title("one-piece", _copy_title(ONE_PIECE))
- cache.update_title("breaking-bad", _copy_title(BREAKING_BAD))
- cache.update_title("dark", _copy_title(DARK))
- cache.update_title("inception", _copy_title(INCEPTION))
- cache._rebuild_indices()
- @pytest.fixture()
- def cache(tmp_path: Path) -> StreamingCache:
- """Liefert einen leeren StreamingCache mit temporaerem Verzeichnis."""
- return StreamingCache(tmp_path)
- @pytest.fixture()
- def seeded_cache(cache: StreamingCache) -> StreamingCache:
- """Liefert einen StreamingCache mit vorbereiteten Testdaten."""
- _seed_cache(cache)
- return cache
- # ===================================================================
- # JSON I/O
- # ===================================================================
- class TestLoadSave:
- """Tests fuer load(), save() und save_async()."""
- def test_load_fehlende_datei_gibt_false(self, cache: StreamingCache) -> None:
- """Laden ohne vorhandene Datei gibt False und leere Daten."""
- ergebnis = cache.load()
- assert ergebnis is False
- assert cache._titles == {}
- assert cache._services == {}
- assert cache._recent == {}
- def test_save_und_load_roundtrip(self, cache: StreamingCache) -> None:
- """Gespeicherte Daten koennen exakt wieder geladen werden."""
- _seed_cache(cache)
- cache.save()
- # Neuen Cache aus demselben Verzeichnis laden
- cache2 = StreamingCache(cache._cache_dir)
- ergebnis = cache2.load()
- assert ergebnis is True
- assert len(cache2._titles) == 5
- assert cache2._titles["naruto-shippuden"]["t"] == "Naruto Shippuden"
- assert cache2._titles["breaking-bad"]["rt"] == 9.5
- async def test_save_async_ruft_to_thread_auf(self, cache: StreamingCache) -> None:
- """save_async() delegiert ueber asyncio.to_thread."""
- _seed_cache(cache)
- with patch("plugins.streaming.catalog.cache.asyncio.to_thread", new_callable=AsyncMock) as mock_to_thread:
- await cache.save_async()
- mock_to_thread.assert_awaited_once_with(cache._save_sync)
- def test_load_korrupte_datei_gibt_false(self, cache: StreamingCache) -> None:
- """Beschaedigte JSON-Datei fuehrt zu False und leeren Daten."""
- cache._file_path.write_text("{kaputt:", encoding="utf-8")
- ergebnis = cache.load()
- assert ergebnis is False
- assert cache._titles == {}
- def test_atomares_schreiben_tmp_rename(self, cache: StreamingCache) -> None:
- """Speicherung nutzt tmp-Datei + Umbenennung (atomar)."""
- _seed_cache(cache)
- cache.save()
- # Nach dem Speichern darf keine .tmp-Datei uebrig sein
- tmp_datei = cache._file_path.with_suffix(".tmp")
- assert not tmp_datei.exists()
- # Die eigentliche Datei muss existieren und gueltiges JSON enthalten
- assert cache._file_path.exists()
- inhalt = json.loads(cache._file_path.read_text(encoding="utf-8"))
- assert "titles" in inhalt
- assert "services" in inhalt
- assert "recent" in inhalt
- # ===================================================================
- # Daten-Mutation
- # ===================================================================
- class TestUpdateTitle:
- """Tests fuer update_title()."""
- def test_neuer_titel_wird_hinzugefuegt(self, cache: StreamingCache) -> None:
- """Ein neuer Titel wird korrekt in _titles gespeichert."""
- cache.update_title("naruto-shippuden", _copy_title(NARUTO))
- assert "naruto-shippuden" in cache._titles
- assert cache._titles["naruto-shippuden"]["t"] == "Naruto Shippuden"
- def test_bestehender_titel_wird_aktualisiert(self, cache: StreamingCache) -> None:
- """Beim Update eines Titels werden on-Dicts zusammengefuehrt."""
- cache.update_title("naruto-shippuden", _copy_title(NARUTO))
- # Zusaetzlichen Dienst hinzufuegen
- update_daten = _make_title(
- "Naruto Shippuden",
- services={"amazon_prime": {"dub": ["de"], "sub": ["de"]}},
- )
- cache.update_title("naruto-shippuden", update_daten)
- on = cache._titles["naruto-shippuden"]["on"]
- # Alte Dienste muessen erhalten bleiben
- assert "crunchyroll" in on
- assert "netflix" in on
- # Neuer Dienst muss vorhanden sein
- assert "amazon_prime" in on
- class TestUpdateServices:
- """Tests fuer update_services()."""
- def test_dienste_werden_gespeichert(self, cache: StreamingCache) -> None:
- """update_services() speichert Dienst-Informationen."""
- dienste = {
- "netflix": {"name": "Netflix", "logo": "netflix.png"},
- "crunchyroll": {"name": "Crunchyroll", "logo": "cr.png"},
- }
- cache.update_services(dienste)
- assert cache._services["netflix"]["name"] == "Netflix"
- assert cache._services["crunchyroll"]["name"] == "Crunchyroll"
- class TestSetRecent:
- """Tests fuer set_recent()."""
- def test_recent_liste_wird_gesetzt(self, cache: StreamingCache) -> None:
- """set_recent() speichert die Slug-Liste fuer einen Dienst."""
- cache.set_recent("netflix", ["naruto-shippuden", "dark"])
- assert cache._recent["netflix"] == ["naruto-shippuden", "dark"]
- class TestPruneMissing:
- """Tests fuer prune_missing()."""
- def test_entfernt_dienst_zuordnung_und_leere_titel(
- self, seeded_cache: StreamingCache,
- ) -> None:
- """prune_missing() entfernt Dienst-Zuordnungen und leere Titel."""
- # dark ist nur auf netflix — wenn netflix entfernt wird, wird dark geloescht
- # naruto ist auf crunchyroll + netflix — netflix wird entfernt, titel bleibt
- aktuelle_slugs = {"breaking-bad", "inception"}
- entfernt = seeded_cache.prune_missing("netflix", aktuelle_slugs)
- # naruto-shippuden verliert netflix, dark verliert netflix
- assert entfernt == 2
- # dark hat keinen Dienst mehr → komplett geloescht
- assert "dark" not in seeded_cache._titles
- # naruto hat noch crunchyroll
- assert "naruto-shippuden" in seeded_cache._titles
- assert "netflix" not in seeded_cache._titles["naruto-shippuden"]["on"]
- # ===================================================================
- # Titel-Suche
- # ===================================================================
- class TestSearchTitle:
- """Tests fuer search_title()."""
- def test_exakte_suche(self, seeded_cache: StreamingCache) -> None:
- """Exakter Titel wird gefunden."""
- ergebnis = seeded_cache.search_title("Naruto Shippuden")
- assert len(ergebnis) >= 1
- assert ergebnis[0]["t"] == "Naruto Shippuden"
- def test_teiluebereinstimmung(self, seeded_cache: StreamingCache) -> None:
- """Teilweise Uebereinstimmung funktioniert."""
- ergebnis = seeded_cache.search_title("Naruto")
- assert len(ergebnis) >= 1
- titel_namen = [t["t"] for t in ergebnis]
- assert "Naruto Shippuden" in titel_namen
- def test_kein_treffer_gibt_leere_liste(self, seeded_cache: StreamingCache) -> None:
- """Unbekannter Suchbegriff gibt leere Liste."""
- ergebnis = seeded_cache.search_title("Nichtexistent XYZ")
- assert ergebnis == []
- def test_limit_wird_eingehalten(self, seeded_cache: StreamingCache) -> None:
- """Das Limit beschraenkt die Ergebnisse."""
- # "action" sollte mehrere Treffer liefern, die "action" im Titel
- # enthalten koennten — wir nutzen stattdessen breiteren Begriff
- ergebnis = seeded_cache.search_title("a", limit=2)
- assert len(ergebnis) <= 2
- class TestFindTitle:
- """Tests fuer find_title()."""
- def test_bester_treffer_wird_gefunden(self, seeded_cache: StreamingCache) -> None:
- """find_title() liefert den besten einzelnen Treffer."""
- ergebnis = seeded_cache.find_title("Breaking Bad")
- assert ergebnis is not None
- assert ergebnis["t"] == "Breaking Bad"
- def test_kein_treffer_gibt_none(self, seeded_cache: StreamingCache) -> None:
- """Unbekannter Suchbegriff gibt None."""
- ergebnis = seeded_cache.find_title("Nichtexistent XYZ")
- assert ergebnis is None
- # ===================================================================
- # Dienst-Suche
- # ===================================================================
- class TestSearchOnService:
- """Tests fuer search_on_service()."""
- def test_filter_nach_dienst(self, seeded_cache: StreamingCache) -> None:
- """Nur Titel auf dem angegebenen Dienst werden zurueckgegeben."""
- ergebnis = seeded_cache.search_on_service("crunchyroll")
- assert len(ergebnis) >= 2 # Naruto + One Piece
- for titel in ergebnis:
- assert "crunchyroll" in titel["on"]
- def test_filter_nach_genre(self, seeded_cache: StreamingCache) -> None:
- """Genre-Filter schraenkt die Ergebnisse ein."""
- ergebnis = seeded_cache.search_on_service("netflix", genre="Drama")
- for titel in ergebnis:
- assert any("Drama" in g for g in titel.get("g", []))
- def test_filter_dienst_und_genre(self, seeded_cache: StreamingCache) -> None:
- """Kombination von Dienst- und Genre-Filter."""
- ergebnis = seeded_cache.search_on_service(
- "netflix", genre="Anime",
- )
- for titel in ergebnis:
- assert "netflix" in titel["on"]
- assert any("Anime" in g for g in titel.get("g", []))
- class TestGetServicesForTitle:
- """Tests fuer get_services_for_title()."""
- def test_alle_dienste_zurueckgegeben(self, seeded_cache: StreamingCache) -> None:
- """Gibt alle Dienste zurueck, auf denen der Titel verfuegbar ist."""
- dienste = seeded_cache.get_services_for_title("naruto-shippuden")
- assert set(dienste) == {"crunchyroll", "netflix"}
- def test_unbekannter_slug_gibt_leere_liste(self, seeded_cache: StreamingCache) -> None:
- """Unbekannter Slug gibt leere Liste."""
- dienste = seeded_cache.get_services_for_title("nichtexistent")
- assert dienste == []
- # ===================================================================
- # Recent
- # ===================================================================
- class TestGetRecent:
- """Tests fuer get_recent()."""
- def test_recent_titel_werden_aufgeloest(self, seeded_cache: StreamingCache) -> None:
- """get_recent() loest Slugs zu Title-Dicts auf."""
- seeded_cache.set_recent("netflix", ["naruto-shippuden", "dark"])
- ergebnis = seeded_cache.get_recent("netflix")
- assert len(ergebnis) == 2
- namen = [t["t"] for t in ergebnis]
- assert "Naruto Shippuden" in namen
- assert "Dark" in namen
- def test_leere_recent_gibt_leere_liste(self, seeded_cache: StreamingCache) -> None:
- """Kein recent-Eintrag gibt leere Liste."""
- ergebnis = seeded_cache.get_recent("disney_plus")
- assert ergebnis == []
- # ===================================================================
- # Genre-Abfragen
- # ===================================================================
- class TestGetByGenre:
- """Tests fuer get_by_genre()."""
- def test_findet_nach_genre(self, seeded_cache: StreamingCache) -> None:
- """Titel des gesuchten Genres werden gefunden."""
- ergebnis = seeded_cache.get_by_genre("Action")
- assert len(ergebnis) >= 2 # Naruto, One Piece, Inception
- for titel in ergebnis:
- assert any("Action" in g for g in titel.get("g", []))
- def test_mit_dienst_filter(self, seeded_cache: StreamingCache) -> None:
- """Genre + Dienst-Filter schraenkt Ergebnisse ein."""
- ergebnis = seeded_cache.get_by_genre("Action", service_id="crunchyroll")
- for titel in ergebnis:
- assert "crunchyroll" in titel["on"]
- def test_case_insensitive(self, seeded_cache: StreamingCache) -> None:
- """Genre-Suche ist case-insensitive."""
- ergebnis_lower = seeded_cache.get_by_genre("action")
- ergebnis_upper = seeded_cache.get_by_genre("Action")
- assert len(ergebnis_lower) == len(ergebnis_upper)
- # ===================================================================
- # Sprach-Filter (Dub/Sub)
- # ===================================================================
- class TestGetWithDub:
- """Tests fuer get_with_dub()."""
- def test_findet_deutsche_synchronisation(self, seeded_cache: StreamingCache) -> None:
- """Titel mit deutscher Synchronisation werden gefunden."""
- ergebnis = seeded_cache.get_with_dub("de")
- assert len(ergebnis) >= 1
- # Breaking Bad, Dark, Inception haben alle deutsches Dub
- namen = [t["t"] for t in ergebnis]
- assert "Breaking Bad" in namen
- def test_filter_nach_dienst(self, seeded_cache: StreamingCache) -> None:
- """Dub-Suche mit Dienst-Filter."""
- ergebnis = seeded_cache.get_with_dub("ja", service_id="crunchyroll")
- for titel in ergebnis:
- cr_info = titel["on"].get("crunchyroll", {})
- assert "ja" in cr_info.get("dub", [])
- def test_filter_nach_genre(self, seeded_cache: StreamingCache) -> None:
- """Dub-Suche mit Genre-Filter."""
- ergebnis = seeded_cache.get_with_dub("de", genre="Drama")
- for titel in ergebnis:
- assert any("Drama" in g for g in titel.get("g", []))
- class TestGetWithSub:
- """Tests fuer get_with_sub()."""
- def test_findet_deutsche_untertitel(self, seeded_cache: StreamingCache) -> None:
- """Titel mit deutschen Untertiteln werden gefunden."""
- ergebnis = seeded_cache.get_with_sub("de")
- assert len(ergebnis) >= 3
- def test_kein_treffer_gibt_leere_liste(self, seeded_cache: StreamingCache) -> None:
- """Unbekannte Sprache gibt leere Liste."""
- ergebnis = seeded_cache.get_with_sub("xx")
- assert ergebnis == []
- # ===================================================================
- # Empfehlungen
- # ===================================================================
- class TestGetRecommendations:
- """Tests fuer get_recommendations()."""
- def test_filter_nach_bewertung(self, seeded_cache: StreamingCache) -> None:
- """Nur Titel ueber der Mindestbewertung werden zurueckgegeben."""
- ergebnis = seeded_cache.get_recommendations("Action", min_rating=8.0)
- for titel in ergebnis:
- assert (titel.get("rt") or 0) >= 8.0
- def test_sortierung_nach_bewertung_absteigend(
- self, seeded_cache: StreamingCache,
- ) -> None:
- """Ergebnisse sind nach Bewertung absteigend sortiert."""
- ergebnis = seeded_cache.get_recommendations("Action", min_rating=0.0)
- bewertungen = [t.get("rt", 0) for t in ergebnis]
- assert bewertungen == sorted(bewertungen, reverse=True)
- # ===================================================================
- # Slug-Generierung
- # ===================================================================
- class TestGenerateSlug:
- """Tests fuer _generate_slug()."""
- def test_einfacher_titel(self) -> None:
- """'Naruto Shippuden' ergibt 'naruto-shippuden'."""
- assert StreamingCache._generate_slug("Naruto Shippuden") == "naruto-shippuden"
- def test_sonderzeichen_entfernt(self) -> None:
- """Sonderzeichen werden durch Bindestriche ersetzt."""
- assert StreamingCache._generate_slug("Jujutsu Kaisen: 0") == "jujutsu-kaisen-0"
- assert StreamingCache._generate_slug("Re:Zero") == "re-zero"
- def test_leerer_string(self) -> None:
- """Leerer String ergibt leeren Slug."""
- assert StreamingCache._generate_slug("") == ""
- def test_nur_sonderzeichen(self) -> None:
- """Nur Sonderzeichen ergibt leeren Slug."""
- assert StreamingCache._generate_slug("!!!???") == ""
- # ===================================================================
- # Statistiken
- # ===================================================================
- class TestGetStats:
- """Tests fuer get_stats()."""
- def test_korrekte_zaehler(self, seeded_cache: StreamingCache) -> None:
- """get_stats() liefert korrekte Zaehler."""
- seeded_cache.update_services({"netflix": {"name": "Netflix"}})
- stats = seeded_cache.get_stats()
- assert stats["title_count"] == 5
- assert stats["service_count"] == 1
- assert stats["genre_count"] >= 4 # Action, Anime, Drama, Thriller, ...
- assert stats["index_service_count"] >= 2 # netflix, crunchyroll, ...
|