Proyecto LCX Dispatcharr multicuenta
Base Image Build / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
Base Image Build / docker (amd64, ubuntu-24.04) (push) Has been cancelled
Base Image Build / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
Base Image Build / create-manifest (push) Has been cancelled
Base Image Build / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
Base Image Build / docker (amd64, ubuntu-24.04) (push) Has been cancelled
Base Image Build / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
Base Image Build / create-manifest (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.epg.tasks import (
|
||||
_NAMED_ENTITY_RE,
|
||||
_detect_xml_encoding,
|
||||
_replace_html_entity,
|
||||
_resolve_html_entities,
|
||||
)
|
||||
|
||||
|
||||
class ReplaceHtmlEntityTests(TestCase):
|
||||
"""Tests for the regex callback that resolves individual HTML entities."""
|
||||
|
||||
def _sub(self, text):
|
||||
return _NAMED_ENTITY_RE.sub(_replace_html_entity, text)
|
||||
|
||||
def test_french_accented(self):
|
||||
self.assertEqual(self._sub("Chaîne Télé"), "Chaîne Télé")
|
||||
|
||||
def test_german_umlauts(self):
|
||||
self.assertEqual(self._sub("München Übersicht ß"), "München Übersicht ß")
|
||||
|
||||
def test_spanish(self):
|
||||
self.assertEqual(self._sub("España ¿Qué?"), "España ¿Qué?")
|
||||
|
||||
def test_portuguese(self):
|
||||
self.assertEqual(self._sub("Comunicação"), "Comunicação")
|
||||
|
||||
def test_scandinavian(self):
|
||||
self.assertEqual(self._sub("Norsk ø å æ"), "Norsk ø å æ")
|
||||
|
||||
def test_greek_letters(self):
|
||||
self.assertEqual(self._sub("αβγ"), "αβγ")
|
||||
|
||||
def test_currency_and_symbols(self):
|
||||
self.assertEqual(self._sub("© € £ ¥"), "© € £ ¥")
|
||||
|
||||
def test_preserves_xml_amp(self):
|
||||
self.assertEqual(self._sub("A & B"), "A & B")
|
||||
|
||||
def test_preserves_xml_lt_gt(self):
|
||||
self.assertEqual(self._sub("<tag>"), "<tag>")
|
||||
|
||||
def test_preserves_xml_quot_apos(self):
|
||||
self.assertEqual(self._sub(""hello'"), ""hello'")
|
||||
|
||||
def test_preserves_uppercase_xml_entities(self):
|
||||
"""&, <, >, " resolve to XML-special chars; must not be replaced."""
|
||||
self.assertEqual(self._sub("&"), "&")
|
||||
self.assertEqual(self._sub("<"), "<")
|
||||
self.assertEqual(self._sub(">"), ">")
|
||||
self.assertEqual(self._sub("""), """)
|
||||
|
||||
def test_partial_entity_match_preserved(self):
|
||||
"""html.unescape can partially match & inside &ersand; — must not corrupt."""
|
||||
self.assertEqual(self._sub("&ersand;"), "&ersand;")
|
||||
|
||||
def test_mixed_html_and_xml_entities(self):
|
||||
self.assertEqual(
|
||||
self._sub("Résumé & Co <test>"),
|
||||
"Résumé & Co <test>",
|
||||
)
|
||||
|
||||
def test_plain_ascii_unchanged(self):
|
||||
self.assertEqual(self._sub("Plain ASCII text"), "Plain ASCII text")
|
||||
|
||||
def test_direct_utf8_unchanged(self):
|
||||
self.assertEqual(self._sub("日本語テレビ"), "日本語テレビ")
|
||||
|
||||
def test_unknown_entity_preserved(self):
|
||||
self.assertEqual(self._sub("&zzfakeentity;"), "&zzfakeentity;")
|
||||
|
||||
|
||||
class ResolveHtmlEntitiesFileTests(TestCase):
|
||||
"""Tests for the file-level preprocessing function."""
|
||||
|
||||
def _make_file(self, content):
|
||||
fd, path = tempfile.mkstemp(suffix=".xml")
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
self.addCleanup(lambda: os.unlink(path) if os.path.exists(path) else None)
|
||||
return path
|
||||
|
||||
def test_resolves_entities_in_file(self):
|
||||
path = self._make_file(
|
||||
'<?xml version="1.0"?>\n<tv><channel><display-name>Télé</display-name></channel></tv>'
|
||||
)
|
||||
_resolve_html_entities(path)
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
self.assertIn("Télé", content)
|
||||
self.assertNotIn("é", content)
|
||||
|
||||
def test_preserves_xml_entities_in_file(self):
|
||||
path = self._make_file("<tv><desc>A & B <C></desc></tv>")
|
||||
_resolve_html_entities(path)
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
self.assertIn("&", content)
|
||||
self.assertIn("<", content)
|
||||
self.assertIn(">", content)
|
||||
|
||||
def test_no_temp_file_left_on_success(self):
|
||||
path = self._make_file("<tv>test</tv>")
|
||||
_resolve_html_entities(path)
|
||||
self.assertFalse(os.path.exists(path + ".entity_tmp"))
|
||||
|
||||
def test_plain_file_unchanged(self):
|
||||
original = '<?xml version="1.0"?>\n<tv><channel><display-name>Plain</display-name></channel></tv>'
|
||||
path = self._make_file(original)
|
||||
_resolve_html_entities(path)
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
self.assertEqual(content, original)
|
||||
|
||||
def test_utf8_content_preserved(self):
|
||||
original = "<tv><channel><display-name>日本語テレビ</display-name></channel></tv>"
|
||||
path = self._make_file(original)
|
||||
_resolve_html_entities(path)
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
self.assertIn("日本語テレビ", content)
|
||||
|
||||
def test_iso_8859_1_encoding(self):
|
||||
"""Files declaring ISO-8859-1 should be read in that encoding."""
|
||||
xml = '<?xml version="1.0" encoding="ISO-8859-1"?>\n<tv><channel><display-name>Chaîne</display-name></channel></tv>'
|
||||
fd, path = tempfile.mkstemp(suffix=".xml")
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(xml.encode("iso-8859-1"))
|
||||
self.addCleanup(lambda: os.unlink(path) if os.path.exists(path) else None)
|
||||
|
||||
_resolve_html_entities(path)
|
||||
with open(path, "r", encoding="iso-8859-1") as f:
|
||||
content = f.read()
|
||||
self.assertIn("Cha\u00eene", content)
|
||||
self.assertNotIn("î", content)
|
||||
|
||||
def test_detect_encoding_utf8_default(self):
|
||||
"""Headers without an encoding declaration default to UTF-8."""
|
||||
self.assertEqual(_detect_xml_encoding(b'<?xml version="1.0"?>'), "utf-8")
|
||||
|
||||
def test_detect_encoding_iso_8859_1(self):
|
||||
"""Encoding is read from the XML declaration."""
|
||||
self.assertEqual(
|
||||
_detect_xml_encoding(b'<?xml version="1.0" encoding="ISO-8859-1"?>'),
|
||||
"ISO-8859-1",
|
||||
)
|
||||
|
||||
def test_detect_encoding_single_quotes(self):
|
||||
"""Encoding detection works with single-quoted attributes."""
|
||||
self.assertEqual(
|
||||
_detect_xml_encoding(b"<?xml version='1.0' encoding='windows-1252'?>"),
|
||||
"windows-1252",
|
||||
)
|
||||
|
||||
def test_detect_encoding_unknown_falls_back(self):
|
||||
"""Unrecognized encoding falls back to UTF-8."""
|
||||
self.assertEqual(
|
||||
_detect_xml_encoding(b'<?xml version="1.0" encoding="x-fake-codec"?>'),
|
||||
"utf-8",
|
||||
)
|
||||
|
||||
def test_iso_8859_1_with_entities_roundtrip(self):
|
||||
"""ISO-8859-1 file with entities: resolved without corrupting existing accented chars."""
|
||||
# Mix of direct ISO-8859-1 chars and HTML entities
|
||||
xml_str = '<?xml version="1.0" encoding="ISO-8859-1"?>\n<tv><channel><display-name>D\xe9j\xe0 émission</display-name></channel></tv>'
|
||||
fd, path = tempfile.mkstemp(suffix=".xml")
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(xml_str.encode("iso-8859-1"))
|
||||
self.addCleanup(lambda: os.unlink(path) if os.path.exists(path) else None)
|
||||
|
||||
_resolve_html_entities(path)
|
||||
with open(path, "r", encoding="iso-8859-1") as f:
|
||||
content = f.read()
|
||||
self.assertIn("D\xe9j\xe0", content, "Existing accented chars should be preserved")
|
||||
self.assertIn("\xe9mission", content, "Entity should be resolved")
|
||||
self.assertNotIn("é", content)
|
||||
|
||||
def test_mismatched_encoding_leaves_file_untouched(self):
|
||||
"""File declaring UTF-8 but containing Latin-1 bytes is left alone."""
|
||||
# \xe9 is valid ISO-8859-1 but invalid as a standalone UTF-8 byte
|
||||
raw = b'<?xml version="1.0" encoding="UTF-8"?>\n<tv><channel><display-name>\xe9</display-name></channel></tv>'
|
||||
fd, path = tempfile.mkstemp(suffix=".xml")
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(raw)
|
||||
self.addCleanup(lambda: os.unlink(path) if os.path.exists(path) else None)
|
||||
|
||||
original_bytes = raw # save for comparison
|
||||
_resolve_html_entities(path)
|
||||
with open(path, "rb") as f:
|
||||
result_bytes = f.read()
|
||||
self.assertEqual(result_bytes, original_bytes, "File should be untouched on decode error")
|
||||
@@ -0,0 +1,655 @@
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from apps.epg.models import EPGData, EPGSource, ProgramData
|
||||
from apps.epg.serializers import ProgramDataSerializer, ProgramDetailSerializer
|
||||
from apps.epg.utils import extract_season_episode, extract_season_episode_from_description
|
||||
|
||||
|
||||
class ProgramDataSerializerTests(TestCase):
|
||||
"""Tests for ProgramDataSerializer season/episode extraction from custom_properties."""
|
||||
|
||||
def setUp(self):
|
||||
self.epg_source = EPGSource.objects.create(
|
||||
name="Test Source", source_type="xmltv"
|
||||
)
|
||||
self.epg = EPGData.objects.create(
|
||||
tvg_id="test-tvg", name="Test EPG", epg_source=self.epg_source
|
||||
)
|
||||
self.now = timezone.now()
|
||||
|
||||
def _create_program(self, **kwargs):
|
||||
defaults = {
|
||||
"epg": self.epg,
|
||||
"start_time": self.now,
|
||||
"end_time": self.now + timezone.timedelta(hours=1),
|
||||
"title": "Test Program",
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return ProgramData.objects.create(**defaults)
|
||||
|
||||
def test_season_and_episode_from_custom_properties(self):
|
||||
"""Season and episode should be extracted from custom_properties."""
|
||||
program = self._create_program(
|
||||
custom_properties={"season": 3, "episode": 5}
|
||||
)
|
||||
data = ProgramDataSerializer(program).data
|
||||
self.assertEqual(data["season"], 3)
|
||||
self.assertEqual(data["episode"], 5)
|
||||
|
||||
def test_season_only_from_custom_properties(self):
|
||||
"""Season should be returned even when episode is absent."""
|
||||
program = self._create_program(custom_properties={"season": 2})
|
||||
data = ProgramDataSerializer(program).data
|
||||
self.assertEqual(data["season"], 2)
|
||||
self.assertIsNone(data["episode"])
|
||||
|
||||
def test_episode_only_from_custom_properties(self):
|
||||
"""Episode should be returned even when season is absent."""
|
||||
program = self._create_program(custom_properties={"episode": 10})
|
||||
data = ProgramDataSerializer(program).data
|
||||
self.assertIsNone(data["season"])
|
||||
self.assertEqual(data["episode"], 10)
|
||||
|
||||
def test_season_episode_null_when_custom_properties_is_none(self):
|
||||
"""Both should be None when custom_properties is None."""
|
||||
program = self._create_program(custom_properties=None)
|
||||
data = ProgramDataSerializer(program).data
|
||||
self.assertIsNone(data["season"])
|
||||
self.assertIsNone(data["episode"])
|
||||
|
||||
def test_season_episode_null_when_custom_properties_is_empty(self):
|
||||
"""Both should be None when custom_properties is an empty dict."""
|
||||
program = self._create_program(custom_properties={})
|
||||
data = ProgramDataSerializer(program).data
|
||||
self.assertIsNone(data["season"])
|
||||
self.assertIsNone(data["episode"])
|
||||
|
||||
def test_season_episode_null_when_keys_absent(self):
|
||||
"""Both should be None when custom_properties has other keys but no season/episode."""
|
||||
program = self._create_program(
|
||||
custom_properties={"categories": ["Drama"], "rating": "TV-14"}
|
||||
)
|
||||
data = ProgramDataSerializer(program).data
|
||||
self.assertIsNone(data["season"])
|
||||
self.assertIsNone(data["episode"])
|
||||
|
||||
def test_sub_title_included_in_serialized_data(self):
|
||||
"""sub_title field should be present in serialized output."""
|
||||
program = self._create_program(sub_title="The Pilot")
|
||||
data = ProgramDataSerializer(program).data
|
||||
self.assertEqual(data["sub_title"], "The Pilot")
|
||||
|
||||
def test_sub_title_null_when_not_set(self):
|
||||
"""sub_title should be None when not set."""
|
||||
program = self._create_program()
|
||||
data = ProgramDataSerializer(program).data
|
||||
self.assertIsNone(data["sub_title"])
|
||||
|
||||
def test_all_expected_fields_present(self):
|
||||
"""Serialized output should contain all expected fields."""
|
||||
program = self._create_program(
|
||||
sub_title="Episode Title",
|
||||
custom_properties={"season": 1, "episode": 1},
|
||||
)
|
||||
data = ProgramDataSerializer(program).data
|
||||
expected_fields = {
|
||||
"id", "start_time", "end_time", "title", "sub_title",
|
||||
"description", "tvg_id", "season", "episode",
|
||||
"is_new", "is_live", "is_premiere", "is_finale",
|
||||
}
|
||||
self.assertEqual(set(data.keys()), expected_fields)
|
||||
|
||||
def test_season_episode_from_onscreen_episode(self):
|
||||
"""Season and episode should be parsed from onscreen_episode string."""
|
||||
program = self._create_program(
|
||||
custom_properties={"onscreen_episode": "S12 E6"}
|
||||
)
|
||||
data = ProgramDataSerializer(program).data
|
||||
self.assertEqual(data["season"], 12)
|
||||
self.assertEqual(data["episode"], 6)
|
||||
|
||||
def test_onscreen_episode_no_space(self):
|
||||
"""Should parse onscreen_episode without space between S and E."""
|
||||
program = self._create_program(
|
||||
custom_properties={"onscreen_episode": "S3E21"}
|
||||
)
|
||||
data = ProgramDataSerializer(program).data
|
||||
self.assertEqual(data["season"], 3)
|
||||
self.assertEqual(data["episode"], 21)
|
||||
|
||||
def test_onscreen_episode_with_part(self):
|
||||
"""Should parse season/episode even when part info follows."""
|
||||
program = self._create_program(
|
||||
custom_properties={"onscreen_episode": "S8 E8 P2/2"}
|
||||
)
|
||||
data = ProgramDataSerializer(program).data
|
||||
self.assertEqual(data["season"], 8)
|
||||
self.assertEqual(data["episode"], 8)
|
||||
|
||||
def test_direct_season_episode_takes_priority_over_onscreen(self):
|
||||
"""Direct season/episode keys should take priority over onscreen parsing."""
|
||||
program = self._create_program(
|
||||
custom_properties={
|
||||
"season": 1, "episode": 2,
|
||||
"onscreen_episode": "S99 E99",
|
||||
}
|
||||
)
|
||||
data = ProgramDataSerializer(program).data
|
||||
self.assertEqual(data["season"], 1)
|
||||
self.assertEqual(data["episode"], 2)
|
||||
|
||||
def test_onscreen_episode_invalid_format(self):
|
||||
"""Should return None for onscreen_episode that doesn't match S/E pattern."""
|
||||
program = self._create_program(
|
||||
custom_properties={"onscreen_episode": "Episode 5"}
|
||||
)
|
||||
data = ProgramDataSerializer(program).data
|
||||
self.assertIsNone(data["season"])
|
||||
self.assertIsNone(data["episode"])
|
||||
|
||||
def test_bulk_serialization_with_mixed_data(self):
|
||||
"""Serializer should handle a mix of programs with and without metadata."""
|
||||
p1 = self._create_program(
|
||||
title="Show A",
|
||||
sub_title="Ep 1",
|
||||
custom_properties={"season": 1, "episode": 1},
|
||||
)
|
||||
p2 = self._create_program(
|
||||
title="Movie B",
|
||||
custom_properties=None,
|
||||
)
|
||||
p3 = self._create_program(
|
||||
title="Show C",
|
||||
custom_properties={},
|
||||
)
|
||||
data = ProgramDataSerializer([p1, p2, p3], many=True).data
|
||||
self.assertEqual(len(data), 3)
|
||||
self.assertEqual(data[0]["season"], 1)
|
||||
self.assertEqual(data[0]["episode"], 1)
|
||||
self.assertIsNone(data[1]["season"])
|
||||
self.assertIsNone(data[1]["episode"])
|
||||
self.assertIsNone(data[2]["season"])
|
||||
self.assertIsNone(data[2]["episode"])
|
||||
|
||||
def test_is_new_true_when_flag_set(self):
|
||||
"""is_new should be True when custom_properties has 'new' flag."""
|
||||
program = self._create_program(custom_properties={"new": True})
|
||||
data = ProgramDataSerializer(program).data
|
||||
self.assertTrue(data["is_new"])
|
||||
|
||||
def test_is_live_true_when_flag_set(self):
|
||||
"""is_live should be True when custom_properties has 'live' flag."""
|
||||
program = self._create_program(custom_properties={"live": True})
|
||||
data = ProgramDataSerializer(program).data
|
||||
self.assertTrue(data["is_live"])
|
||||
|
||||
def test_is_premiere_true_when_flag_set(self):
|
||||
"""is_premiere should be True when custom_properties has 'premiere' flag."""
|
||||
program = self._create_program(custom_properties={"premiere": True})
|
||||
data = ProgramDataSerializer(program).data
|
||||
self.assertTrue(data["is_premiere"])
|
||||
|
||||
def test_flags_false_when_not_set(self):
|
||||
"""All boolean flags should be False when not in custom_properties."""
|
||||
program = self._create_program(custom_properties={"season": 1})
|
||||
data = ProgramDataSerializer(program).data
|
||||
self.assertFalse(data["is_new"])
|
||||
self.assertFalse(data["is_live"])
|
||||
self.assertFalse(data["is_premiere"])
|
||||
|
||||
def test_flags_false_when_custom_properties_none(self):
|
||||
"""All boolean flags should be False when custom_properties is None."""
|
||||
program = self._create_program(custom_properties=None)
|
||||
data = ProgramDataSerializer(program).data
|
||||
self.assertFalse(data["is_new"])
|
||||
self.assertFalse(data["is_live"])
|
||||
self.assertFalse(data["is_premiere"])
|
||||
|
||||
def test_flags_false_when_custom_properties_empty(self):
|
||||
"""All boolean flags should be False when custom_properties is empty."""
|
||||
program = self._create_program(custom_properties={})
|
||||
data = ProgramDataSerializer(program).data
|
||||
self.assertFalse(data["is_new"])
|
||||
self.assertFalse(data["is_live"])
|
||||
self.assertFalse(data["is_premiere"])
|
||||
|
||||
def test_multiple_flags_set(self):
|
||||
"""Multiple flags can be true simultaneously."""
|
||||
program = self._create_program(
|
||||
custom_properties={"new": True, "live": True, "premiere": True}
|
||||
)
|
||||
data = ProgramDataSerializer(program).data
|
||||
self.assertTrue(data["is_new"])
|
||||
self.assertTrue(data["is_live"])
|
||||
self.assertTrue(data["is_premiere"])
|
||||
|
||||
def test_flags_with_season_episode(self):
|
||||
"""Flags should work alongside season/episode data."""
|
||||
program = self._create_program(
|
||||
custom_properties={"season": 5, "episode": 1, "new": True, "premiere": True}
|
||||
)
|
||||
data = ProgramDataSerializer(program).data
|
||||
self.assertEqual(data["season"], 5)
|
||||
self.assertEqual(data["episode"], 1)
|
||||
self.assertTrue(data["is_new"])
|
||||
self.assertFalse(data["is_live"])
|
||||
self.assertTrue(data["is_premiere"])
|
||||
|
||||
def test_is_finale_from_premiere_text_season_finale(self):
|
||||
"""is_finale should be True when premiere_text contains 'Season Finale'."""
|
||||
program = self._create_program(
|
||||
custom_properties={"premiere": True, "premiere_text": "Season Finale"}
|
||||
)
|
||||
data = ProgramDataSerializer(program).data
|
||||
self.assertTrue(data["is_finale"])
|
||||
|
||||
def test_is_finale_from_premiere_text_series_finale(self):
|
||||
"""is_finale should be True when premiere_text contains 'Series Finale'."""
|
||||
program = self._create_program(
|
||||
custom_properties={"premiere": True, "premiere_text": "Series Finale"}
|
||||
)
|
||||
data = ProgramDataSerializer(program).data
|
||||
self.assertTrue(data["is_finale"])
|
||||
|
||||
def test_is_finale_case_insensitive(self):
|
||||
"""is_finale detection should be case-insensitive."""
|
||||
program = self._create_program(
|
||||
custom_properties={"premiere": True, "premiere_text": "SEASON FINALE"}
|
||||
)
|
||||
data = ProgramDataSerializer(program).data
|
||||
self.assertTrue(data["is_finale"])
|
||||
|
||||
def test_is_finale_false_for_premiere_text(self):
|
||||
"""is_finale should be False when premiere_text is 'Season Premiere'."""
|
||||
program = self._create_program(
|
||||
custom_properties={"premiere": True, "premiere_text": "Season Premiere"}
|
||||
)
|
||||
data = ProgramDataSerializer(program).data
|
||||
self.assertFalse(data["is_finale"])
|
||||
|
||||
def test_is_finale_false_when_no_premiere_text(self):
|
||||
"""is_finale should be False when premiere_text is absent."""
|
||||
program = self._create_program(
|
||||
custom_properties={"premiere": True}
|
||||
)
|
||||
data = ProgramDataSerializer(program).data
|
||||
self.assertFalse(data["is_finale"])
|
||||
|
||||
def test_is_finale_false_when_custom_properties_none(self):
|
||||
"""is_finale should be False when custom_properties is None."""
|
||||
program = self._create_program(custom_properties=None)
|
||||
data = ProgramDataSerializer(program).data
|
||||
self.assertFalse(data["is_finale"])
|
||||
|
||||
|
||||
class ExtractSeasonEpisodeHelperTests(TestCase):
|
||||
"""Tests for the shared extract_season_episode helper function."""
|
||||
|
||||
def test_both_present(self):
|
||||
season, episode = extract_season_episode({"season": 3, "episode": 5})
|
||||
self.assertEqual(season, 3)
|
||||
self.assertEqual(episode, 5)
|
||||
|
||||
def test_fallback_to_onscreen(self):
|
||||
season, episode = extract_season_episode({"onscreen_episode": "S12 E6"})
|
||||
self.assertEqual(season, 12)
|
||||
self.assertEqual(episode, 6)
|
||||
|
||||
def test_direct_values_override_onscreen(self):
|
||||
season, episode = extract_season_episode({
|
||||
"season": 1, "episode": 2, "onscreen_episode": "S99 E99"
|
||||
})
|
||||
self.assertEqual(season, 1)
|
||||
self.assertEqual(episode, 2)
|
||||
|
||||
def test_empty_dict(self):
|
||||
season, episode = extract_season_episode({})
|
||||
self.assertIsNone(season)
|
||||
self.assertIsNone(episode)
|
||||
|
||||
def test_partial_with_onscreen_fill(self):
|
||||
"""Direct season + onscreen episode fills the gap."""
|
||||
season, episode = extract_season_episode({
|
||||
"season": 5, "onscreen_episode": "S5E10"
|
||||
})
|
||||
self.assertEqual(season, 5)
|
||||
self.assertEqual(episode, 10)
|
||||
|
||||
def test_description_fallback_s_e_format(self):
|
||||
"""S01E01 in description should be used as third-tier fallback."""
|
||||
season, episode = extract_season_episode({}, description="S2 E31 The Episode Title")
|
||||
self.assertEqual(season, 2)
|
||||
self.assertEqual(episode, 31)
|
||||
|
||||
def test_description_fallback_season_episode_format(self):
|
||||
season, episode = extract_season_episode({}, description="Season 3 Episode 12 Some Title")
|
||||
self.assertEqual(season, 3)
|
||||
self.assertEqual(episode, 12)
|
||||
|
||||
def test_description_fallback_nxnn_format(self):
|
||||
season, episode = extract_season_episode({}, description="5x03 Episode Name")
|
||||
self.assertEqual(season, 5)
|
||||
self.assertEqual(episode, 3)
|
||||
|
||||
def test_description_not_used_when_cp_has_both(self):
|
||||
"""Description fallback should not override existing custom_properties values."""
|
||||
season, episode = extract_season_episode(
|
||||
{"season": 1, "episode": 2}, description="S99 E99 Fake"
|
||||
)
|
||||
self.assertEqual(season, 1)
|
||||
self.assertEqual(episode, 2)
|
||||
|
||||
def test_description_not_used_when_onscreen_provides_both(self):
|
||||
season, episode = extract_season_episode(
|
||||
{"onscreen_episode": "S3E5"}, description="S99 E99 Fake"
|
||||
)
|
||||
self.assertEqual(season, 3)
|
||||
self.assertEqual(episode, 5)
|
||||
|
||||
def test_description_fills_gap_after_partial_onscreen(self):
|
||||
"""If onscreen provides only season, description can fill episode."""
|
||||
# onscreen_episode "S5" doesn't match the S/E pattern, so no values from onscreen
|
||||
# description provides both
|
||||
season, episode = extract_season_episode(
|
||||
{"season": 5}, description="S5 E10 Title"
|
||||
)
|
||||
self.assertEqual(season, 5)
|
||||
self.assertEqual(episode, 10)
|
||||
|
||||
def test_description_none_is_safe(self):
|
||||
season, episode = extract_season_episode({}, description=None)
|
||||
self.assertIsNone(season)
|
||||
self.assertIsNone(episode)
|
||||
|
||||
def test_description_empty_string_is_safe(self):
|
||||
season, episode = extract_season_episode({}, description="")
|
||||
self.assertIsNone(season)
|
||||
self.assertIsNone(episode)
|
||||
|
||||
|
||||
class ExtractSeasonEpisodeFromDescriptionTests(TestCase):
|
||||
"""Tests for extract_season_episode_from_description() in tasks.py."""
|
||||
|
||||
def test_s_e_compact(self):
|
||||
s, e, cleaned = extract_season_episode_from_description("S2E31 The Kevin Episode")
|
||||
self.assertEqual(s, 2)
|
||||
self.assertEqual(e, 31)
|
||||
self.assertEqual(cleaned, "The Kevin Episode")
|
||||
|
||||
def test_s_e_with_space(self):
|
||||
s, e, cleaned = extract_season_episode_from_description("S2 E31 The Kevin Episode")
|
||||
self.assertEqual(s, 2)
|
||||
self.assertEqual(e, 31)
|
||||
self.assertEqual(cleaned, "The Kevin Episode")
|
||||
|
||||
def test_season_episode_words(self):
|
||||
s, e, cleaned = extract_season_episode_from_description("Season 3 Episode 12 Title Here")
|
||||
self.assertEqual(s, 3)
|
||||
self.assertEqual(e, 12)
|
||||
self.assertEqual(cleaned, "Title Here")
|
||||
|
||||
def test_nxnn_format(self):
|
||||
s, e, cleaned = extract_season_episode_from_description("5x03 Episode Name")
|
||||
self.assertEqual(s, 5)
|
||||
self.assertEqual(e, 3)
|
||||
self.assertEqual(cleaned, "Episode Name")
|
||||
|
||||
def test_leading_dash(self):
|
||||
s, e, cleaned = extract_season_episode_from_description("- S1E5 Title")
|
||||
self.assertEqual(s, 1)
|
||||
self.assertEqual(e, 5)
|
||||
self.assertEqual(cleaned, "Title")
|
||||
|
||||
def test_case_insensitive(self):
|
||||
s, e, cleaned = extract_season_episode_from_description("s10e20 Lower Case")
|
||||
self.assertEqual(s, 10)
|
||||
self.assertEqual(e, 20)
|
||||
self.assertEqual(cleaned, "Lower Case")
|
||||
|
||||
def test_no_match_returns_original(self):
|
||||
s, e, cleaned = extract_season_episode_from_description("Just a normal description")
|
||||
self.assertIsNone(s)
|
||||
self.assertIsNone(e)
|
||||
self.assertEqual(cleaned, "Just a normal description")
|
||||
|
||||
def test_none_input(self):
|
||||
s, e, cleaned = extract_season_episode_from_description(None)
|
||||
self.assertIsNone(s)
|
||||
self.assertIsNone(e)
|
||||
self.assertIsNone(cleaned)
|
||||
|
||||
def test_empty_string(self):
|
||||
s, e, cleaned = extract_season_episode_from_description("")
|
||||
self.assertIsNone(s)
|
||||
self.assertIsNone(e)
|
||||
self.assertEqual(cleaned, "")
|
||||
|
||||
def test_mid_string_s_e_not_matched(self):
|
||||
"""S/E in middle of description should NOT be matched (anchored to start)."""
|
||||
s, e, cleaned = extract_season_episode_from_description("Some intro text S1E5 title")
|
||||
self.assertIsNone(s)
|
||||
self.assertIsNone(e)
|
||||
self.assertEqual(cleaned, "Some intro text S1E5 title")
|
||||
|
||||
|
||||
class ProgramDataSerializerDescriptionFallbackTests(TestCase):
|
||||
"""Integration tests: serializer uses description fallback for S/E."""
|
||||
|
||||
def setUp(self):
|
||||
self.epg_source = EPGSource.objects.create(
|
||||
name="Test Source", source_type="xmltv"
|
||||
)
|
||||
self.epg = EPGData.objects.create(
|
||||
tvg_id="test-tvg", name="Test EPG", epg_source=self.epg_source
|
||||
)
|
||||
self.now = timezone.now()
|
||||
|
||||
def _create_program(self, **kwargs):
|
||||
defaults = {
|
||||
"epg": self.epg,
|
||||
"start_time": self.now,
|
||||
"end_time": self.now + timezone.timedelta(hours=1),
|
||||
"title": "Test Program",
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return ProgramData.objects.create(**defaults)
|
||||
|
||||
def test_se_from_description_when_no_cp(self):
|
||||
program = self._create_program(
|
||||
custom_properties={},
|
||||
description="S2 E5 The Episode Title",
|
||||
)
|
||||
data = ProgramDataSerializer(program).data
|
||||
self.assertEqual(data["season"], 2)
|
||||
self.assertEqual(data["episode"], 5)
|
||||
|
||||
def test_se_from_description_not_used_when_cp_has_values(self):
|
||||
program = self._create_program(
|
||||
custom_properties={"season": 1, "episode": 1},
|
||||
description="S99 E99 Fake",
|
||||
)
|
||||
data = ProgramDataSerializer(program).data
|
||||
self.assertEqual(data["season"], 1)
|
||||
self.assertEqual(data["episode"], 1)
|
||||
|
||||
|
||||
class ProgramDetailSerializerTests(TestCase):
|
||||
"""Tests for ProgramDetailSerializer — rich field extraction from custom_properties."""
|
||||
|
||||
def setUp(self):
|
||||
self.epg_source = EPGSource.objects.create(
|
||||
name="Test Source", source_type="xmltv"
|
||||
)
|
||||
self.epg = EPGData.objects.create(
|
||||
tvg_id="test-tvg", name="Test EPG", epg_source=self.epg_source
|
||||
)
|
||||
self.now = timezone.now()
|
||||
|
||||
def _create_program(self, **kwargs):
|
||||
defaults = {
|
||||
"epg": self.epg,
|
||||
"start_time": self.now,
|
||||
"end_time": self.now + timezone.timedelta(hours=1),
|
||||
"title": "Test Program",
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return ProgramData.objects.create(**defaults)
|
||||
|
||||
def test_all_detail_fields_present(self):
|
||||
"""Detail serializer should include all expected fields."""
|
||||
program = self._create_program(custom_properties={"season": 1, "episode": 1})
|
||||
data = ProgramDetailSerializer(program).data
|
||||
expected_fields = {
|
||||
"id", "start_time", "end_time", "title", "sub_title", "description", "tvg_id",
|
||||
"season", "episode", "is_new", "is_live", "is_premiere", "is_finale",
|
||||
"categories", "rating", "rating_system", "star_ratings",
|
||||
"credits", "video_quality", "aspect_ratio", "stereo", "is_previously_shown",
|
||||
"country", "language", "production_date", "original_air_date",
|
||||
"imdb_id", "tmdb_id", "tvdb_id", "icon", "images",
|
||||
}
|
||||
self.assertEqual(set(data.keys()), expected_fields)
|
||||
|
||||
def test_categories_extraction(self):
|
||||
program = self._create_program(
|
||||
custom_properties={"categories": ["Drama", "Thriller"]}
|
||||
)
|
||||
data = ProgramDetailSerializer(program).data
|
||||
self.assertEqual(data["categories"], ["Drama", "Thriller"])
|
||||
|
||||
def test_categories_empty_when_absent(self):
|
||||
program = self._create_program(custom_properties={})
|
||||
data = ProgramDetailSerializer(program).data
|
||||
self.assertEqual(data["categories"], [])
|
||||
|
||||
def test_rating_extraction(self):
|
||||
program = self._create_program(
|
||||
custom_properties={"rating": "TV-14", "rating_system": "VCHIP"}
|
||||
)
|
||||
data = ProgramDetailSerializer(program).data
|
||||
self.assertEqual(data["rating"], "TV-14")
|
||||
self.assertEqual(data["rating_system"], "VCHIP")
|
||||
|
||||
def test_star_ratings_extraction(self):
|
||||
program = self._create_program(
|
||||
custom_properties={"star_ratings": [{"value": "8.5/10", "system": "IMDB"}]}
|
||||
)
|
||||
data = ProgramDetailSerializer(program).data
|
||||
self.assertEqual(len(data["star_ratings"]), 1)
|
||||
self.assertEqual(data["star_ratings"][0]["value"], "8.5/10")
|
||||
|
||||
def test_credits_extraction(self):
|
||||
program = self._create_program(
|
||||
custom_properties={
|
||||
"credits": {
|
||||
"actor": [{"name": "Bryan Cranston", "role": "Walter White"}],
|
||||
"director": ["Rian Johnson"],
|
||||
"writer": ["Moira Walley-Beckett"],
|
||||
}
|
||||
}
|
||||
)
|
||||
data = ProgramDetailSerializer(program).data
|
||||
self.assertEqual(len(data["credits"]["actors"]), 1)
|
||||
self.assertEqual(data["credits"]["actors"][0]["name"], "Bryan Cranston")
|
||||
self.assertEqual(data["credits"]["directors"], ["Rian Johnson"])
|
||||
self.assertEqual(data["credits"]["writers"], ["Moira Walley-Beckett"])
|
||||
|
||||
def test_credits_empty_when_absent(self):
|
||||
program = self._create_program(custom_properties={})
|
||||
data = ProgramDetailSerializer(program).data
|
||||
self.assertEqual(data["credits"]["actors"], [])
|
||||
self.assertEqual(data["credits"]["directors"], [])
|
||||
|
||||
def test_video_quality_extraction(self):
|
||||
program = self._create_program(
|
||||
custom_properties={"video": {"quality": "HDTV", "aspect": "16:9"}}
|
||||
)
|
||||
data = ProgramDetailSerializer(program).data
|
||||
self.assertEqual(data["video_quality"], "HDTV")
|
||||
self.assertEqual(data["aspect_ratio"], "16:9")
|
||||
|
||||
def test_audio_extraction(self):
|
||||
program = self._create_program(
|
||||
custom_properties={"audio": {"stereo": "Dolby Digital"}}
|
||||
)
|
||||
data = ProgramDetailSerializer(program).data
|
||||
self.assertEqual(data["stereo"], "Dolby Digital")
|
||||
|
||||
def test_geographic_fields(self):
|
||||
program = self._create_program(
|
||||
custom_properties={"country": "US", "language": "en"}
|
||||
)
|
||||
data = ProgramDetailSerializer(program).data
|
||||
self.assertEqual(data["country"], "US")
|
||||
self.assertEqual(data["language"], "en")
|
||||
|
||||
def test_original_air_date(self):
|
||||
program = self._create_program(
|
||||
custom_properties={
|
||||
"previously_shown_details": {"start": "2013-09-15"}
|
||||
}
|
||||
)
|
||||
data = ProgramDetailSerializer(program).data
|
||||
self.assertEqual(data["original_air_date"], "2013-09-15")
|
||||
|
||||
def test_external_ids(self):
|
||||
program = self._create_program(
|
||||
custom_properties={
|
||||
"imdb.com_id": "tt0903747",
|
||||
"themoviedb.org_id": "1396",
|
||||
"thetvdb.com_id": "81189",
|
||||
}
|
||||
)
|
||||
data = ProgramDetailSerializer(program).data
|
||||
self.assertEqual(data["imdb_id"], "tt0903747")
|
||||
self.assertEqual(data["tmdb_id"], "1396")
|
||||
self.assertEqual(data["tvdb_id"], "81189")
|
||||
|
||||
def test_images_extraction(self):
|
||||
program = self._create_program(
|
||||
custom_properties={
|
||||
"icon": "https://example.com/icon.png",
|
||||
"images": [{"url": "https://example.com/poster.jpg", "type": "poster"}],
|
||||
}
|
||||
)
|
||||
data = ProgramDetailSerializer(program).data
|
||||
self.assertEqual(data["icon"], "https://example.com/icon.png")
|
||||
self.assertEqual(len(data["images"]), 1)
|
||||
|
||||
def test_null_custom_properties_returns_safe_defaults(self):
|
||||
"""All enriched fields should be null/empty when custom_properties is None."""
|
||||
program = self._create_program(custom_properties=None)
|
||||
data = ProgramDetailSerializer(program).data
|
||||
self.assertIsNone(data["season"])
|
||||
self.assertIsNone(data["episode"])
|
||||
self.assertEqual(data["categories"], [])
|
||||
self.assertIsNone(data["rating"])
|
||||
self.assertEqual(data["star_ratings"], [])
|
||||
self.assertEqual(data["credits"]["actors"], [])
|
||||
self.assertIsNone(data["video_quality"])
|
||||
self.assertIsNone(data["country"])
|
||||
self.assertIsNone(data["imdb_id"])
|
||||
self.assertEqual(data["images"], [])
|
||||
|
||||
def test_season_episode_uses_shared_helper(self):
|
||||
"""Detail serializer should use the same onscreen_episode fallback."""
|
||||
program = self._create_program(
|
||||
custom_properties={"onscreen_episode": "S5E14"}
|
||||
)
|
||||
data = ProgramDetailSerializer(program).data
|
||||
self.assertEqual(data["season"], 5)
|
||||
self.assertEqual(data["episode"], 14)
|
||||
|
||||
def test_status_flags_match_slim_serializer(self):
|
||||
"""Status flags should produce identical results as ProgramDataSerializer."""
|
||||
program = self._create_program(
|
||||
custom_properties={
|
||||
"new": True, "live": True, "premiere": True,
|
||||
"premiere_text": "Season Finale",
|
||||
}
|
||||
)
|
||||
slim = ProgramDataSerializer(program).data
|
||||
detail = ProgramDetailSerializer(program).data
|
||||
self.assertEqual(slim["is_new"], detail["is_new"])
|
||||
self.assertEqual(slim["is_live"], detail["is_live"])
|
||||
self.assertEqual(slim["is_premiere"], detail["is_premiere"])
|
||||
self.assertEqual(slim["is_finale"], detail["is_finale"])
|
||||
Reference in New Issue
Block a user