From c1d9b6bf54de3cbb618eff6900d8a188159509b0 Mon Sep 17 00:00:00 2001 From: unai Date: Mon, 2 Feb 2026 16:28:51 +0000 Subject: [PATCH] feat: add include_text and exclude_text filters to settings and playlist tests --- tests/conftest.py | 51 ++++++ tests/test_config.py | 130 +++++++++++++++ tests/test_playlist.py | 371 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 552 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 30cd858..d530788 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -28,6 +28,55 @@ def sample_channels(): ] +@pytest.fixture +def channels_for_filtering(): + """Fixture con canales para probar filtros de inclusión/exclusión.""" + return [ + { + "name": "ESPN Sports", + "stream_id": 201, + "stream_icon": "http://example.com/espn.png", + "category_id": "sports", + }, + { + "name": "HBO Movies", + "stream_id": 202, + "stream_icon": "http://example.com/hbo.png", + "category_id": "movies", + }, + { + "name": "CNN News", + "stream_id": 203, + "stream_icon": "http://example.com/cnn.png", + "category_id": "news", + }, + { + "name": "Sports Center ESPN", + "stream_id": 204, + "stream_icon": "http://example.com/sc.png", + "category_id": "sports", + }, + { + "name": "BBC World", + "stream_id": 205, + "stream_icon": "http://example.com/bbc.png", + "category_id": "news", + }, + { + "name": "Adult Content", + "stream_id": 206, + "stream_icon": "http://example.com/adult.png", + "category_id": "adult", + }, + { + "name": "FOX News", + "stream_id": 207, + "stream_icon": "http://example.com/fox.png", + "category_id": "news", + }, + ] + + @pytest.fixture def empty_channels(): """Fixture con lista vacía de canales.""" @@ -51,5 +100,7 @@ def mock_settings(monkeypatch): port = 8080 update_interval = 60 output_file = "test_playlist.m3u" + include_text = [] + exclude_text = [] return MockSettings() diff --git a/tests/test_config.py b/tests/test_config.py index 80ce59f..5b85a5a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -175,3 +175,133 @@ class TestSettings: # No debe lanzar excepción settings = Settings() assert not hasattr(settings, "unknown_field") + + # ======================================== + # Tests para include_text y exclude_text + # ======================================== + + def test_settings_include_text_default_empty_list(self, monkeypatch): + """Test: include_text tiene valor por defecto lista vacía.""" + monkeypatch.setenv("HOST", "http://test.com") + monkeypatch.setenv("USERNAME", "user") + monkeypatch.setenv("PASSWORD", "pass") + + from m3u_list_builder.config import Settings + + settings = Settings(_env_file=None) + + assert settings.include_text == [] + assert isinstance(settings.include_text, list) + + def test_settings_exclude_text_default_empty_list(self, monkeypatch): + """Test: exclude_text tiene valor por defecto lista vacía.""" + monkeypatch.setenv("HOST", "http://test.com") + monkeypatch.setenv("USERNAME", "user") + monkeypatch.setenv("PASSWORD", "pass") + + from m3u_list_builder.config import Settings + + settings = Settings(_env_file=None) + + assert settings.exclude_text == [] + assert isinstance(settings.exclude_text, list) + + def test_settings_include_text_from_env_json(self, monkeypatch): + """Test: include_text acepta formato JSON desde variable de entorno.""" + monkeypatch.setenv("HOST", "http://test.com") + monkeypatch.setenv("USERNAME", "user") + monkeypatch.setenv("PASSWORD", "pass") + monkeypatch.setenv("INCLUDE_TEXT", '["ESPN", "CNN"]') + + from m3u_list_builder.config import Settings + + settings = Settings(_env_file=None) + + assert settings.include_text == ["ESPN", "CNN"] + + def test_settings_exclude_text_from_env_json(self, monkeypatch): + """Test: exclude_text acepta formato JSON desde variable de entorno.""" + monkeypatch.setenv("HOST", "http://test.com") + monkeypatch.setenv("USERNAME", "user") + monkeypatch.setenv("PASSWORD", "pass") + monkeypatch.setenv("EXCLUDE_TEXT", '["Adult", "XXX"]') + + from m3u_list_builder.config import Settings + + settings = Settings(_env_file=None) + + assert settings.exclude_text == ["Adult", "XXX"] + + def test_settings_include_and_exclude_together(self, monkeypatch): + """Test: include_text y exclude_text pueden usarse juntos.""" + monkeypatch.setenv("HOST", "http://test.com") + monkeypatch.setenv("USERNAME", "user") + monkeypatch.setenv("PASSWORD", "pass") + monkeypatch.setenv("INCLUDE_TEXT", '["Sports", "News"]') + monkeypatch.setenv("EXCLUDE_TEXT", '["Adult"]') + + from m3u_list_builder.config import Settings + + settings = Settings(_env_file=None) + + assert settings.include_text == ["Sports", "News"] + assert settings.exclude_text == ["Adult"] + + def test_settings_include_text_single_value(self, monkeypatch): + """Test: include_text acepta un solo valor en lista.""" + monkeypatch.setenv("HOST", "http://test.com") + monkeypatch.setenv("USERNAME", "user") + monkeypatch.setenv("PASSWORD", "pass") + monkeypatch.setenv("INCLUDE_TEXT", '["OnlyThis"]') + + from m3u_list_builder.config import Settings + + settings = Settings(_env_file=None) + + assert settings.include_text == ["OnlyThis"] + assert len(settings.include_text) == 1 + + def test_settings_exclude_text_single_value(self, monkeypatch): + """Test: exclude_text acepta un solo valor en lista.""" + monkeypatch.setenv("HOST", "http://test.com") + monkeypatch.setenv("USERNAME", "user") + monkeypatch.setenv("PASSWORD", "pass") + monkeypatch.setenv("EXCLUDE_TEXT", '["BlockThis"]') + + from m3u_list_builder.config import Settings + + settings = Settings(_env_file=None) + + assert settings.exclude_text == ["BlockThis"] + assert len(settings.exclude_text) == 1 + + def test_settings_include_text_empty_json_array(self, monkeypatch): + """Test: include_text acepta array JSON vacío.""" + monkeypatch.setenv("HOST", "http://test.com") + monkeypatch.setenv("USERNAME", "user") + monkeypatch.setenv("PASSWORD", "pass") + monkeypatch.setenv("INCLUDE_TEXT", "[]") + + from m3u_list_builder.config import Settings + + settings = Settings(_env_file=None) + + assert settings.include_text == [] + + def test_settings_from_env_file_with_filters(self, tmp_path, monkeypatch): + """Test: Settings lee filtros desde archivo .env.""" + env_file = tmp_path / ".env" + env_file.write_text( + 'HOST=http://test.com\n' + 'USERNAME=user\n' + 'PASSWORD=pass\n' + 'INCLUDE_TEXT=["HBO", "ESPN"]\n' + 'EXCLUDE_TEXT=["Adult"]\n' + ) + + from m3u_list_builder.config import Settings + + settings = Settings(_env_file=str(env_file)) + + assert settings.include_text == ["HBO", "ESPN"] + assert settings.exclude_text == ["Adult"] diff --git a/tests/test_playlist.py b/tests/test_playlist.py index fb9d39a..142c3f3 100644 --- a/tests/test_playlist.py +++ b/tests/test_playlist.py @@ -243,6 +243,8 @@ class TestPlaylistManager: mock_settings.username = "testuser" mock_settings.password = "testpass" mock_settings.output_file = "playlist.m3u" + mock_settings.include_text = [] + mock_settings.exclude_text = [] from m3u_list_builder.playlist import PlaylistManager @@ -277,6 +279,8 @@ class TestPlaylistManager: mock_settings.username = "user" mock_settings.password = "pass" mock_settings.output_file = "playlist.m3u" + mock_settings.include_text = [] + mock_settings.exclude_text = [] from m3u_list_builder.playlist import PlaylistManager @@ -306,6 +310,8 @@ class TestPlaylistManager: mock_settings.username = "testuser" mock_settings.password = "testpass" mock_settings.output_file = "playlist.m3u" + mock_settings.include_text = [] + mock_settings.exclude_text = [] from m3u_list_builder.playlist import PlaylistManager @@ -459,3 +465,368 @@ class TestPlaylistManager: manager.loop() assert iterations == 3 + + # ======================================== + # Tests para filtros include_text y exclude_text + # Complejidad Alta - Múltiples paths de filtrado + # ======================================== + + def test_write_m3u_include_text_filters_by_prefix( + self, temp_dir, channels_for_filtering + ): + """Test: include_text filtra canales que EMPIEZAN con el texto.""" + with patch("m3u_list_builder.playlist.PUBLIC_DIR", temp_dir): + with patch("m3u_list_builder.playlist.settings") as mock_settings: + mock_settings.host = "http://test-iptv.com" + mock_settings.username = "testuser" + mock_settings.password = "testpass" + mock_settings.output_file = "playlist.m3u" + mock_settings.include_text = ["ESPN"] + mock_settings.exclude_text = [] + + from m3u_list_builder.playlist import PlaylistManager + + manager = PlaylistManager() + manager._write_m3u(channels_for_filtering) + + output_file = temp_dir / "playlist.m3u" + content = output_file.read_text() + + # "ESPN Sports" empieza con "ESPN" - DEBE incluirse + assert "ESPN Sports" in content + # "Sports Center ESPN" NO empieza con "ESPN" - NO debe incluirse + assert "Sports Center ESPN" not in content + # Otros canales no deben estar + assert "HBO Movies" not in content + assert "CNN News" not in content + + def test_write_m3u_include_text_case_insensitive( + self, temp_dir, channels_for_filtering + ): + """Test: include_text es case-insensitive.""" + with patch("m3u_list_builder.playlist.PUBLIC_DIR", temp_dir): + with patch("m3u_list_builder.playlist.settings") as mock_settings: + mock_settings.host = "http://test-iptv.com" + mock_settings.username = "testuser" + mock_settings.password = "testpass" + mock_settings.output_file = "playlist.m3u" + mock_settings.include_text = ["espn"] # minúsculas + mock_settings.exclude_text = [] + + from m3u_list_builder.playlist import PlaylistManager + + manager = PlaylistManager() + manager._write_m3u(channels_for_filtering) + + output_file = temp_dir / "playlist.m3u" + content = output_file.read_text() + + # "ESPN Sports" debe incluirse aunque el filtro está en minúsculas + assert "ESPN Sports" in content + + def test_write_m3u_include_text_multiple_prefixes( + self, temp_dir, channels_for_filtering + ): + """Test: include_text acepta múltiples prefijos (OR logic).""" + with patch("m3u_list_builder.playlist.PUBLIC_DIR", temp_dir): + with patch("m3u_list_builder.playlist.settings") as mock_settings: + mock_settings.host = "http://test-iptv.com" + mock_settings.username = "testuser" + mock_settings.password = "testpass" + mock_settings.output_file = "playlist.m3u" + mock_settings.include_text = ["ESPN", "CNN", "BBC"] + mock_settings.exclude_text = [] + + from m3u_list_builder.playlist import PlaylistManager + + manager = PlaylistManager() + manager._write_m3u(channels_for_filtering) + + output_file = temp_dir / "playlist.m3u" + content = output_file.read_text() + + # Canales que empiezan con ESPN, CNN o BBC + assert "ESPN Sports" in content + assert "CNN News" in content + assert "BBC World" in content + # Canales que no empiezan con ninguno + assert "HBO Movies" not in content + assert "FOX News" not in content + + def test_write_m3u_include_text_empty_includes_all( + self, temp_dir, channels_for_filtering + ): + """Test: include_text vacío incluye todos los canales.""" + with patch("m3u_list_builder.playlist.PUBLIC_DIR", temp_dir): + with patch("m3u_list_builder.playlist.settings") as mock_settings: + mock_settings.host = "http://test-iptv.com" + mock_settings.username = "testuser" + mock_settings.password = "testpass" + mock_settings.output_file = "playlist.m3u" + mock_settings.include_text = [] + mock_settings.exclude_text = [] + + from m3u_list_builder.playlist import PlaylistManager + + manager = PlaylistManager() + manager._write_m3u(channels_for_filtering) + + output_file = temp_dir / "playlist.m3u" + content = output_file.read_text() + + # Todos los canales deben estar incluidos + for channel in channels_for_filtering: + assert channel["name"] in content + + def test_write_m3u_exclude_text_filters_by_contains( + self, temp_dir, channels_for_filtering + ): + """Test: exclude_text filtra canales que CONTIENEN el texto.""" + with patch("m3u_list_builder.playlist.PUBLIC_DIR", temp_dir): + with patch("m3u_list_builder.playlist.settings") as mock_settings: + mock_settings.host = "http://test-iptv.com" + mock_settings.username = "testuser" + mock_settings.password = "testpass" + mock_settings.output_file = "playlist.m3u" + mock_settings.include_text = [] + mock_settings.exclude_text = ["Adult"] + + from m3u_list_builder.playlist import PlaylistManager + + manager = PlaylistManager() + manager._write_m3u(channels_for_filtering) + + output_file = temp_dir / "playlist.m3u" + content = output_file.read_text() + + # "Adult Content" contiene "Adult" - NO debe incluirse + assert "Adult Content" not in content + # Otros canales deben estar + assert "ESPN Sports" in content + assert "HBO Movies" in content + + def test_write_m3u_exclude_text_matches_anywhere_in_name( + self, temp_dir, channels_for_filtering + ): + """Test: exclude_text busca en cualquier parte del nombre.""" + with patch("m3u_list_builder.playlist.PUBLIC_DIR", temp_dir): + with patch("m3u_list_builder.playlist.settings") as mock_settings: + mock_settings.host = "http://test-iptv.com" + mock_settings.username = "testuser" + mock_settings.password = "testpass" + mock_settings.output_file = "playlist.m3u" + mock_settings.include_text = [] + mock_settings.exclude_text = ["News"] # Está en medio/final + + from m3u_list_builder.playlist import PlaylistManager + + manager = PlaylistManager() + manager._write_m3u(channels_for_filtering) + + output_file = temp_dir / "playlist.m3u" + content = output_file.read_text() + + # "CNN News" y "FOX News" contienen "News" - NO deben incluirse + assert "CNN News" not in content + assert "FOX News" not in content + # Otros canales deben estar + assert "ESPN Sports" in content + assert "HBO Movies" in content + + def test_write_m3u_exclude_text_case_insensitive( + self, temp_dir, channels_for_filtering + ): + """Test: exclude_text es case-insensitive.""" + with patch("m3u_list_builder.playlist.PUBLIC_DIR", temp_dir): + with patch("m3u_list_builder.playlist.settings") as mock_settings: + mock_settings.host = "http://test-iptv.com" + mock_settings.username = "testuser" + mock_settings.password = "testpass" + mock_settings.output_file = "playlist.m3u" + mock_settings.include_text = [] + mock_settings.exclude_text = ["adult"] # minúsculas + + from m3u_list_builder.playlist import PlaylistManager + + manager = PlaylistManager() + manager._write_m3u(channels_for_filtering) + + output_file = temp_dir / "playlist.m3u" + content = output_file.read_text() + + # "Adult Content" debe excluirse aunque el filtro está en minúsculas + assert "Adult Content" not in content + + def test_write_m3u_exclude_text_multiple_patterns( + self, temp_dir, channels_for_filtering + ): + """Test: exclude_text acepta múltiples patrones (OR logic).""" + with patch("m3u_list_builder.playlist.PUBLIC_DIR", temp_dir): + with patch("m3u_list_builder.playlist.settings") as mock_settings: + mock_settings.host = "http://test-iptv.com" + mock_settings.username = "testuser" + mock_settings.password = "testpass" + mock_settings.output_file = "playlist.m3u" + mock_settings.include_text = [] + mock_settings.exclude_text = ["Adult", "News"] + + from m3u_list_builder.playlist import PlaylistManager + + manager = PlaylistManager() + manager._write_m3u(channels_for_filtering) + + output_file = temp_dir / "playlist.m3u" + content = output_file.read_text() + + # Canales que contienen "Adult" o "News" no deben incluirse + assert "Adult Content" not in content + assert "CNN News" not in content + assert "FOX News" not in content + # Otros canales deben estar + assert "ESPN Sports" in content + assert "HBO Movies" in content + assert "BBC World" in content + + def test_write_m3u_include_and_exclude_combined( + self, temp_dir, channels_for_filtering + ): + """Test: include_text y exclude_text funcionan juntos.""" + with patch("m3u_list_builder.playlist.PUBLIC_DIR", temp_dir): + with patch("m3u_list_builder.playlist.settings") as mock_settings: + mock_settings.host = "http://test-iptv.com" + mock_settings.username = "testuser" + mock_settings.password = "testpass" + mock_settings.output_file = "playlist.m3u" + # Solo canales que empiezan con "ESPN" o "Sports" + mock_settings.include_text = ["ESPN", "Sports"] + # Pero excluir los que contienen "Center" + mock_settings.exclude_text = ["Center"] + + from m3u_list_builder.playlist import PlaylistManager + + manager = PlaylistManager() + manager._write_m3u(channels_for_filtering) + + output_file = temp_dir / "playlist.m3u" + content = output_file.read_text() + + # "ESPN Sports" empieza con "ESPN" y no contiene "Center" - INCLUIR + assert "ESPN Sports" in content + # "Sports Center ESPN" empieza con "Sports" pero contiene "Center" - EXCLUIR + assert "Sports Center ESPN" not in content + + def test_write_m3u_include_filters_before_exclude( + self, temp_dir, channels_for_filtering + ): + """Test: include se aplica antes que exclude.""" + with patch("m3u_list_builder.playlist.PUBLIC_DIR", temp_dir): + with patch("m3u_list_builder.playlist.settings") as mock_settings: + mock_settings.host = "http://test-iptv.com" + mock_settings.username = "testuser" + mock_settings.password = "testpass" + mock_settings.output_file = "playlist.m3u" + mock_settings.include_text = ["CNN"] + mock_settings.exclude_text = ["News"] + + from m3u_list_builder.playlist import PlaylistManager + + manager = PlaylistManager() + manager._write_m3u(channels_for_filtering) + + output_file = temp_dir / "playlist.m3u" + content = output_file.read_text() + + # "CNN News" empieza con "CNN" pero contiene "News" - EXCLUIR + assert "CNN News" not in content + # Solo debe quedar el header + assert content.strip() == "#EXTM3U" + + def test_write_m3u_no_match_for_include_results_in_empty( + self, temp_dir, channels_for_filtering + ): + """Test: si ningún canal coincide con include, resultado vacío.""" + with patch("m3u_list_builder.playlist.PUBLIC_DIR", temp_dir): + with patch("m3u_list_builder.playlist.settings") as mock_settings: + mock_settings.host = "http://test-iptv.com" + mock_settings.username = "testuser" + mock_settings.password = "testpass" + mock_settings.output_file = "playlist.m3u" + mock_settings.include_text = ["NONEXISTENT"] + mock_settings.exclude_text = [] + + from m3u_list_builder.playlist import PlaylistManager + + manager = PlaylistManager() + manager._write_m3u(channels_for_filtering) + + output_file = temp_dir / "playlist.m3u" + content = output_file.read_text() + + # Solo header, sin canales + assert content == "#EXTM3U\n" + + def test_write_m3u_exclude_all_results_in_empty( + self, temp_dir, channels_for_filtering + ): + """Test: si todos los canales son excluidos, resultado vacío.""" + with patch("m3u_list_builder.playlist.PUBLIC_DIR", temp_dir): + with patch("m3u_list_builder.playlist.settings") as mock_settings: + mock_settings.host = "http://test-iptv.com" + mock_settings.username = "testuser" + mock_settings.password = "testpass" + mock_settings.output_file = "playlist.m3u" + mock_settings.include_text = [] + # Excluir texto común a todos + mock_settings.exclude_text = ["a", "e", "i", "o", "u"] + + from m3u_list_builder.playlist import PlaylistManager + + manager = PlaylistManager() + manager._write_m3u(channels_for_filtering) + + output_file = temp_dir / "playlist.m3u" + content = output_file.read_text() + + # Solo header, sin canales (todos tienen vocales) + assert content == "#EXTM3U\n" + + def test_write_m3u_include_startswith_not_contains(self, temp_dir): + """Test: include usa startswith, NO contains - verificación explícita.""" + channels = [ + {"name": "ABC News", "stream_id": 1, "stream_icon": "", "category_id": "1"}, + { + "name": "News ABC", + "stream_id": 2, + "stream_icon": "", + "category_id": "1", + }, # Contiene ABC pero no empieza + { + "name": "ZABC Channel", + "stream_id": 3, + "stream_icon": "", + "category_id": "1", + }, # Contiene ABC pero no empieza + ] + + with patch("m3u_list_builder.playlist.PUBLIC_DIR", temp_dir): + with patch("m3u_list_builder.playlist.settings") as mock_settings: + mock_settings.host = "http://test-iptv.com" + mock_settings.username = "testuser" + mock_settings.password = "testpass" + mock_settings.output_file = "playlist.m3u" + mock_settings.include_text = ["ABC"] + mock_settings.exclude_text = [] + + from m3u_list_builder.playlist import PlaylistManager + + manager = PlaylistManager() + manager._write_m3u(channels) + + output_file = temp_dir / "playlist.m3u" + content = output_file.read_text() + + # Solo "ABC News" debe estar - empieza con ABC + assert "ABC News" in content + # "News ABC" y "ZABC Channel" NO deben estar + assert "News ABC" not in content + assert "ZABC Channel" not in content