feat: write all required NFO tags on creation
This commit is contained in:
231
tests/unit/test_nfo_creation_tags.py
Normal file
231
tests/unit/test_nfo_creation_tags.py
Normal file
@@ -0,0 +1,231 @@
|
||||
"""Unit tests for NFO tag creation — Task 0.
|
||||
|
||||
Verifies that ``tmdb_to_nfo_model`` populates every required NFO tag and
|
||||
that ``generate_tvshow_nfo`` writes all of them to the XML output.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from lxml import etree
|
||||
|
||||
from src.core.entities.nfo_models import TVShowNFO
|
||||
from src.core.utils.nfo_generator import generate_tvshow_nfo
|
||||
from src.core.utils.nfo_mapper import _extract_rating_by_country, tmdb_to_nfo_model
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers / fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _fake_get_image_url(path: str, size: str) -> str:
|
||||
"""Minimal stand-in for TMDBClient.get_image_url used in tests."""
|
||||
return f"https://image.tmdb.org/t/p/{size}{path}"
|
||||
|
||||
|
||||
MINIMAL_TMDB: Dict[str, Any] = {
|
||||
"id": 12345,
|
||||
"name": "Test Show",
|
||||
"original_name": "テストショー",
|
||||
"overview": "A great overview.",
|
||||
"tagline": "The best tagline.",
|
||||
"first_air_date": "2023-04-01",
|
||||
"status": "Continuing",
|
||||
"episode_run_time": [24],
|
||||
"vote_average": 8.5,
|
||||
"vote_count": 200,
|
||||
"genres": [{"id": 1, "name": "Animation"}, {"id": 2, "name": "Action"}],
|
||||
"networks": [{"id": 10, "name": "AT-X"}],
|
||||
"origin_country": ["JP"],
|
||||
"production_countries": [],
|
||||
"external_ids": {"imdb_id": "tt1234567", "tvdb_id": 99999},
|
||||
"poster_path": "/poster.jpg",
|
||||
"backdrop_path": "/backdrop.jpg",
|
||||
"images": {"logos": [{"file_path": "/logo.png"}]},
|
||||
"credits": {
|
||||
"cast": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Actor One",
|
||||
"character": "Hero",
|
||||
"profile_path": "/actor1.jpg",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
CONTENT_RATINGS_DE_US: Dict[str, Any] = {
|
||||
"results": [
|
||||
{"iso_3166_1": "DE", "rating": "12"},
|
||||
{"iso_3166_1": "US", "rating": "TV-PG"},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def nfo_model() -> TVShowNFO:
|
||||
"""Return a fully-populated TVShowNFO from MINIMAL_TMDB data."""
|
||||
return tmdb_to_nfo_model(MINIMAL_TMDB, CONTENT_RATINGS_DE_US, _fake_get_image_url)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tmdb_to_nfo_model — field mapping tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_tmdb_to_nfo_model_sets_originaltitle(nfo_model: TVShowNFO) -> None:
|
||||
assert nfo_model.originaltitle == "テストショー"
|
||||
|
||||
|
||||
def test_tmdb_to_nfo_model_sets_year_from_first_air_date(nfo_model: TVShowNFO) -> None:
|
||||
assert nfo_model.year == 2023
|
||||
|
||||
|
||||
def test_tmdb_to_nfo_model_sets_plot_from_overview(nfo_model: TVShowNFO) -> None:
|
||||
assert nfo_model.plot == "A great overview."
|
||||
|
||||
|
||||
def test_tmdb_to_nfo_model_sets_runtime(nfo_model: TVShowNFO) -> None:
|
||||
assert nfo_model.runtime == 24
|
||||
|
||||
|
||||
def test_tmdb_to_nfo_model_sets_premiered(nfo_model: TVShowNFO) -> None:
|
||||
assert nfo_model.premiered == "2023-04-01"
|
||||
|
||||
|
||||
def test_tmdb_to_nfo_model_sets_status(nfo_model: TVShowNFO) -> None:
|
||||
assert nfo_model.status == "Continuing"
|
||||
|
||||
|
||||
def test_tmdb_to_nfo_model_sets_imdbid(nfo_model: TVShowNFO) -> None:
|
||||
assert nfo_model.imdbid == "tt1234567"
|
||||
|
||||
|
||||
def test_tmdb_to_nfo_model_sets_genres(nfo_model: TVShowNFO) -> None:
|
||||
assert "Animation" in nfo_model.genre
|
||||
assert "Action" in nfo_model.genre
|
||||
|
||||
|
||||
def test_tmdb_to_nfo_model_sets_studios_from_networks(nfo_model: TVShowNFO) -> None:
|
||||
assert "AT-X" in nfo_model.studio
|
||||
|
||||
|
||||
def test_tmdb_to_nfo_model_sets_country(nfo_model: TVShowNFO) -> None:
|
||||
assert "JP" in nfo_model.country
|
||||
|
||||
|
||||
def test_tmdb_to_nfo_model_sets_actors(nfo_model: TVShowNFO) -> None:
|
||||
assert len(nfo_model.actors) == 1
|
||||
assert nfo_model.actors[0].name == "Actor One"
|
||||
assert nfo_model.actors[0].role == "Hero"
|
||||
|
||||
|
||||
def test_tmdb_to_nfo_model_sets_watched_false(nfo_model: TVShowNFO) -> None:
|
||||
assert nfo_model.watched is False
|
||||
|
||||
|
||||
def test_tmdb_to_nfo_model_sets_tagline(nfo_model: TVShowNFO) -> None:
|
||||
assert nfo_model.tagline == "The best tagline."
|
||||
|
||||
|
||||
def test_tmdb_to_nfo_model_sets_outline_from_overview(nfo_model: TVShowNFO) -> None:
|
||||
assert nfo_model.outline == "A great overview."
|
||||
|
||||
|
||||
def test_tmdb_to_nfo_model_sets_sorttitle_from_name(nfo_model: TVShowNFO) -> None:
|
||||
assert nfo_model.sorttitle == "Test Show"
|
||||
|
||||
|
||||
def test_tmdb_to_nfo_model_sets_dateadded(nfo_model: TVShowNFO) -> None:
|
||||
assert nfo_model.dateadded is not None
|
||||
# Must match YYYY-MM-DD HH:MM:SS
|
||||
datetime.strptime(nfo_model.dateadded, "%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def test_tmdb_to_nfo_model_sets_mpaa_from_content_ratings(nfo_model: TVShowNFO) -> None:
|
||||
assert nfo_model.mpaa == "TV-PG"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _extract_rating_by_country
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_extract_rating_by_country_returns_us_rating() -> None:
|
||||
ratings = {"results": [{"iso_3166_1": "US", "rating": "TV-14"}]}
|
||||
assert _extract_rating_by_country(ratings, "US") == "TV-14"
|
||||
|
||||
|
||||
def test_extract_rating_by_country_returns_none_when_no_match() -> None:
|
||||
ratings = {"results": [{"iso_3166_1": "DE", "rating": "12"}]}
|
||||
assert _extract_rating_by_country(ratings, "US") is None
|
||||
|
||||
|
||||
def test_extract_rating_by_country_handles_empty_results() -> None:
|
||||
assert _extract_rating_by_country({"results": []}, "US") is None
|
||||
assert _extract_rating_by_country({}, "US") is None
|
||||
assert _extract_rating_by_country(None, "US") is None # type: ignore[arg-type]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# generate_tvshow_nfo — XML output tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_xml(xml_str: str) -> etree._Element:
|
||||
return etree.fromstring(xml_str.encode("utf-8"))
|
||||
|
||||
|
||||
def test_generate_nfo_includes_all_required_tags(nfo_model: TVShowNFO) -> None:
|
||||
xml_str = generate_tvshow_nfo(nfo_model)
|
||||
root = _parse_xml(xml_str)
|
||||
|
||||
required = [
|
||||
"title", "originaltitle", "year", "plot", "runtime",
|
||||
"premiered", "status", "imdbid", "genre", "studio",
|
||||
"country", "actor", "watched", "tagline", "outline",
|
||||
"sorttitle", "dateadded",
|
||||
]
|
||||
for tag in required:
|
||||
elements = root.findall(f".//{tag}")
|
||||
assert elements, f"Missing required tag: <{tag}>"
|
||||
# At least one element must have non-empty text
|
||||
assert any(e.text for e in elements), f"Tag <{tag}> is empty"
|
||||
|
||||
|
||||
def test_generate_nfo_writes_watched_false(nfo_model: TVShowNFO) -> None:
|
||||
xml_str = generate_tvshow_nfo(nfo_model)
|
||||
root = _parse_xml(xml_str)
|
||||
watched = root.find(".//watched")
|
||||
assert watched is not None
|
||||
assert watched.text == "false"
|
||||
|
||||
|
||||
def test_generate_nfo_minimal_model_does_not_crash() -> None:
|
||||
minimal = TVShowNFO(title="Minimal Show")
|
||||
xml_str = generate_tvshow_nfo(minimal)
|
||||
assert "<title>Minimal Show</title>" in xml_str
|
||||
|
||||
|
||||
def test_generate_nfo_writes_fsk_over_mpaa_when_prefer_fsk() -> None:
|
||||
nfo = TVShowNFO(title="Test", fsk="FSK 16", mpaa="TV-MA")
|
||||
with patch("src.core.utils.nfo_generator.settings") as mock_settings:
|
||||
mock_settings.nfo_prefer_fsk_rating = True
|
||||
xml_str = generate_tvshow_nfo(nfo)
|
||||
root = _parse_xml(xml_str)
|
||||
mpaa_elem = root.find(".//mpaa")
|
||||
assert mpaa_elem is not None
|
||||
assert mpaa_elem.text == "FSK 16"
|
||||
|
||||
|
||||
def test_generate_nfo_writes_mpaa_when_no_fsk() -> None:
|
||||
nfo = TVShowNFO(title="Test", fsk=None, mpaa="TV-14")
|
||||
with patch("src.core.utils.nfo_generator.settings") as mock_settings:
|
||||
mock_settings.nfo_prefer_fsk_rating = True
|
||||
xml_str = generate_tvshow_nfo(nfo)
|
||||
root = _parse_xml(xml_str)
|
||||
mpaa_elem = root.find(".//mpaa")
|
||||
assert mpaa_elem is not None
|
||||
assert mpaa_elem.text == "TV-14"
|
||||
@@ -7,6 +7,7 @@ import pytest
|
||||
|
||||
from src.core.services.nfo_service import NFOService
|
||||
from src.core.services.tmdb_client import TMDBAPIError
|
||||
from src.core.utils.nfo_mapper import _extract_fsk_rating, tmdb_to_nfo_model
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -86,22 +87,22 @@ class TestFSKRatingExtraction:
|
||||
|
||||
def test_extract_fsk_rating_de(self, nfo_service, mock_content_ratings_de):
|
||||
"""Test extraction of German FSK rating."""
|
||||
fsk = nfo_service._extract_fsk_rating(mock_content_ratings_de)
|
||||
fsk = _extract_fsk_rating(mock_content_ratings_de)
|
||||
assert fsk == "FSK 16"
|
||||
|
||||
def test_extract_fsk_rating_no_de(self, nfo_service, mock_content_ratings_no_de):
|
||||
"""Test extraction when no German rating available."""
|
||||
fsk = nfo_service._extract_fsk_rating(mock_content_ratings_no_de)
|
||||
fsk = _extract_fsk_rating(mock_content_ratings_no_de)
|
||||
assert fsk is None
|
||||
|
||||
def test_extract_fsk_rating_empty(self, nfo_service):
|
||||
"""Test extraction with empty content ratings."""
|
||||
fsk = nfo_service._extract_fsk_rating({})
|
||||
fsk = _extract_fsk_rating({})
|
||||
assert fsk is None
|
||||
|
||||
def test_extract_fsk_rating_none(self, nfo_service):
|
||||
"""Test extraction with None input."""
|
||||
fsk = nfo_service._extract_fsk_rating(None)
|
||||
fsk = _extract_fsk_rating(None)
|
||||
assert fsk is None
|
||||
|
||||
def test_extract_fsk_all_values(self, nfo_service):
|
||||
@@ -118,7 +119,7 @@ class TestFSKRatingExtraction:
|
||||
content_ratings = {
|
||||
"results": [{"iso_3166_1": "DE", "rating": rating_value}]
|
||||
}
|
||||
fsk = nfo_service._extract_fsk_rating(content_ratings)
|
||||
fsk = _extract_fsk_rating(content_ratings)
|
||||
assert fsk == expected_fsk
|
||||
|
||||
def test_extract_fsk_already_formatted(self, nfo_service):
|
||||
@@ -126,7 +127,7 @@ class TestFSKRatingExtraction:
|
||||
content_ratings = {
|
||||
"results": [{"iso_3166_1": "DE", "rating": "FSK 12"}]
|
||||
}
|
||||
fsk = nfo_service._extract_fsk_rating(content_ratings)
|
||||
fsk = _extract_fsk_rating(content_ratings)
|
||||
assert fsk == "FSK 12"
|
||||
|
||||
def test_extract_fsk_partial_match(self, nfo_service):
|
||||
@@ -134,7 +135,7 @@ class TestFSKRatingExtraction:
|
||||
content_ratings = {
|
||||
"results": [{"iso_3166_1": "DE", "rating": "Ab 16 Jahren"}]
|
||||
}
|
||||
fsk = nfo_service._extract_fsk_rating(content_ratings)
|
||||
fsk = _extract_fsk_rating(content_ratings)
|
||||
assert fsk == "FSK 16"
|
||||
|
||||
def test_extract_fsk_unmapped_value(self, nfo_service):
|
||||
@@ -142,7 +143,7 @@ class TestFSKRatingExtraction:
|
||||
content_ratings = {
|
||||
"results": [{"iso_3166_1": "DE", "rating": "Unknown"}]
|
||||
}
|
||||
fsk = nfo_service._extract_fsk_rating(content_ratings)
|
||||
fsk = _extract_fsk_rating(content_ratings)
|
||||
assert fsk is None
|
||||
|
||||
|
||||
@@ -213,12 +214,15 @@ class TestYearExtraction:
|
||||
class TestTMDBToNFOModel:
|
||||
"""Test conversion of TMDB data to NFO model."""
|
||||
|
||||
@patch.object(NFOService, '_extract_fsk_rating')
|
||||
@patch('src.core.utils.nfo_mapper._extract_fsk_rating')
|
||||
def test_tmdb_to_nfo_with_fsk(self, mock_extract_fsk, nfo_service, mock_tmdb_data, mock_content_ratings_de):
|
||||
"""Test conversion includes FSK rating."""
|
||||
mock_extract_fsk.return_value = "FSK 16"
|
||||
|
||||
nfo_model = nfo_service._tmdb_to_nfo_model(mock_tmdb_data, mock_content_ratings_de)
|
||||
nfo_model = tmdb_to_nfo_model(
|
||||
mock_tmdb_data, mock_content_ratings_de,
|
||||
nfo_service.tmdb_client.get_image_url, nfo_service.image_size
|
||||
)
|
||||
|
||||
assert nfo_model.title == "Attack on Titan"
|
||||
assert nfo_model.fsk == "FSK 16"
|
||||
@@ -227,7 +231,10 @@ class TestTMDBToNFOModel:
|
||||
|
||||
def test_tmdb_to_nfo_without_content_ratings(self, nfo_service, mock_tmdb_data):
|
||||
"""Test conversion without content ratings."""
|
||||
nfo_model = nfo_service._tmdb_to_nfo_model(mock_tmdb_data, None)
|
||||
nfo_model = tmdb_to_nfo_model(
|
||||
mock_tmdb_data, None,
|
||||
nfo_service.tmdb_client.get_image_url, nfo_service.image_size
|
||||
)
|
||||
|
||||
assert nfo_model.title == "Attack on Titan"
|
||||
assert nfo_model.fsk is None
|
||||
@@ -235,7 +242,10 @@ class TestTMDBToNFOModel:
|
||||
|
||||
def test_tmdb_to_nfo_basic_fields(self, nfo_service, mock_tmdb_data):
|
||||
"""Test that all basic fields are correctly mapped."""
|
||||
nfo_model = nfo_service._tmdb_to_nfo_model(mock_tmdb_data)
|
||||
nfo_model = tmdb_to_nfo_model(
|
||||
mock_tmdb_data, None,
|
||||
nfo_service.tmdb_client.get_image_url, nfo_service.image_size
|
||||
)
|
||||
|
||||
assert nfo_model.title == "Attack on Titan"
|
||||
assert nfo_model.originaltitle == "進撃の巨人"
|
||||
@@ -247,7 +257,10 @@ class TestTMDBToNFOModel:
|
||||
|
||||
def test_tmdb_to_nfo_ids(self, nfo_service, mock_tmdb_data):
|
||||
"""Test that all IDs are correctly mapped."""
|
||||
nfo_model = nfo_service._tmdb_to_nfo_model(mock_tmdb_data)
|
||||
nfo_model = tmdb_to_nfo_model(
|
||||
mock_tmdb_data, None,
|
||||
nfo_service.tmdb_client.get_image_url, nfo_service.image_size
|
||||
)
|
||||
|
||||
assert nfo_model.tmdbid == 1429
|
||||
assert nfo_model.imdbid == "tt2560140"
|
||||
@@ -256,7 +269,10 @@ class TestTMDBToNFOModel:
|
||||
|
||||
def test_tmdb_to_nfo_genres_studios(self, nfo_service, mock_tmdb_data):
|
||||
"""Test that genres and studios are correctly mapped."""
|
||||
nfo_model = nfo_service._tmdb_to_nfo_model(mock_tmdb_data)
|
||||
nfo_model = tmdb_to_nfo_model(
|
||||
mock_tmdb_data, None,
|
||||
nfo_service.tmdb_client.get_image_url, nfo_service.image_size
|
||||
)
|
||||
|
||||
assert "Animation" in nfo_model.genre
|
||||
assert "Sci-Fi & Fantasy" in nfo_model.genre
|
||||
@@ -265,7 +281,10 @@ class TestTMDBToNFOModel:
|
||||
|
||||
def test_tmdb_to_nfo_ratings(self, nfo_service, mock_tmdb_data):
|
||||
"""Test that ratings are correctly mapped."""
|
||||
nfo_model = nfo_service._tmdb_to_nfo_model(mock_tmdb_data)
|
||||
nfo_model = tmdb_to_nfo_model(
|
||||
mock_tmdb_data, None,
|
||||
nfo_service.tmdb_client.get_image_url, nfo_service.image_size
|
||||
)
|
||||
|
||||
assert len(nfo_model.ratings) == 1
|
||||
assert nfo_model.ratings[0].name == "themoviedb"
|
||||
@@ -274,7 +293,10 @@ class TestTMDBToNFOModel:
|
||||
|
||||
def test_tmdb_to_nfo_cast(self, nfo_service, mock_tmdb_data):
|
||||
"""Test that cast is correctly mapped."""
|
||||
nfo_model = nfo_service._tmdb_to_nfo_model(mock_tmdb_data)
|
||||
nfo_model = tmdb_to_nfo_model(
|
||||
mock_tmdb_data, None,
|
||||
nfo_service.tmdb_client.get_image_url, nfo_service.image_size
|
||||
)
|
||||
|
||||
assert len(nfo_model.actors) == 1
|
||||
assert nfo_model.actors[0].name == "Yuki Kaji"
|
||||
@@ -969,7 +991,10 @@ class TestTMDBToNFOModelEdgeCases:
|
||||
"original_name": "Original"
|
||||
}
|
||||
|
||||
nfo_model = nfo_service._tmdb_to_nfo_model(minimal_data)
|
||||
nfo_model = tmdb_to_nfo_model(
|
||||
minimal_data, None,
|
||||
nfo_service.tmdb_client.get_image_url, nfo_service.image_size
|
||||
)
|
||||
|
||||
assert nfo_model.title == "Series"
|
||||
assert nfo_model.originaltitle == "Original"
|
||||
@@ -978,7 +1003,10 @@ class TestTMDBToNFOModelEdgeCases:
|
||||
|
||||
def test_tmdb_to_nfo_with_all_cast(self, nfo_service, mock_tmdb_data):
|
||||
"""Test conversion includes cast members."""
|
||||
nfo_model = nfo_service._tmdb_to_nfo_model(mock_tmdb_data)
|
||||
nfo_model = tmdb_to_nfo_model(
|
||||
mock_tmdb_data, None,
|
||||
nfo_service.tmdb_client.get_image_url, nfo_service.image_size
|
||||
)
|
||||
|
||||
assert len(nfo_model.actors) >= 1
|
||||
assert nfo_model.actors[0].name == "Yuki Kaji"
|
||||
@@ -986,7 +1014,10 @@ class TestTMDBToNFOModelEdgeCases:
|
||||
|
||||
def test_tmdb_to_nfo_multiple_genres(self, nfo_service, mock_tmdb_data):
|
||||
"""Test conversion with multiple genres."""
|
||||
nfo_model = nfo_service._tmdb_to_nfo_model(mock_tmdb_data)
|
||||
nfo_model = tmdb_to_nfo_model(
|
||||
mock_tmdb_data, None,
|
||||
nfo_service.tmdb_client.get_image_url, nfo_service.image_size
|
||||
)
|
||||
|
||||
assert "Animation" in nfo_model.genre
|
||||
assert "Sci-Fi & Fantasy" in nfo_model.genre
|
||||
@@ -1001,7 +1032,7 @@ class TestExtractFSKRatingEdgeCases:
|
||||
"results": [{"iso_3166_1": "DE", "rating": "Ab 16 Jahren"}]
|
||||
}
|
||||
|
||||
fsk = nfo_service._extract_fsk_rating(content_ratings)
|
||||
fsk = _extract_fsk_rating(content_ratings)
|
||||
assert fsk == "FSK 16"
|
||||
|
||||
def test_extract_fsk_multiple_numbers(self, nfo_service):
|
||||
@@ -1010,7 +1041,7 @@ class TestExtractFSKRatingEdgeCases:
|
||||
"results": [{"iso_3166_1": "DE", "rating": "Rating 6 or 12"}]
|
||||
}
|
||||
|
||||
fsk = nfo_service._extract_fsk_rating(content_ratings)
|
||||
fsk = _extract_fsk_rating(content_ratings)
|
||||
# Should find 12 first in the search order
|
||||
assert fsk == "FSK 12"
|
||||
|
||||
@@ -1018,17 +1049,17 @@ class TestExtractFSKRatingEdgeCases:
|
||||
"""Test extraction with empty results list."""
|
||||
content_ratings = {"results": []}
|
||||
|
||||
fsk = nfo_service._extract_fsk_rating(content_ratings)
|
||||
fsk = _extract_fsk_rating(content_ratings)
|
||||
assert fsk is None
|
||||
|
||||
def test_extract_fsk_none_input(self, nfo_service):
|
||||
"""Test extraction with None input."""
|
||||
fsk = nfo_service._extract_fsk_rating(None)
|
||||
fsk = _extract_fsk_rating(None)
|
||||
assert fsk is None
|
||||
|
||||
def test_extract_fsk_missing_results_key(self, nfo_service):
|
||||
"""Test extraction when results key is missing."""
|
||||
fsk = nfo_service._extract_fsk_rating({})
|
||||
fsk = _extract_fsk_rating({})
|
||||
assert fsk is None
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user