diff --git a/.gitea/workflows/unit-tests.yml b/.gitea/workflows/unit-tests.yml new file mode 100644 index 0000000..27377e9 --- /dev/null +++ b/.gitea/workflows/unit-tests.yml @@ -0,0 +1,32 @@ +name: Unit tests + +on: + push: + branches: [ main, Next ] + pull_request: + branches: [ main, Next ] + +jobs: + unit: + name: Run unit tests + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: "https://gitea.com/actions/checkout@v5" + + - name: Create venv and install project + run: | + set -euo pipefail + python3 -m venv .venv + . .venv/bin/activate + python -m pip install --upgrade pip + # Install project (editable) and test deps + python -m pip install -e .[test] || python -m pip install -e . + python -m pip install pytest + + - name: Run tests + run: | + set -euo pipefail + . .venv/bin/activate + pytest -q diff --git a/.gitignore b/.gitignore index ac4e615..469383e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ #Custom for this project config/yt-playlist-config.json /tmp* +*.code-workspace # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..7eb673f --- /dev/null +++ b/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +testpaths = tests +# Collect all standardized tests using the conventional pattern +python_files = test_*.py +addopts = -q diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d6d1267 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,9 @@ +import pytest + +from tests.dummy_config import DummyConfig + + +@pytest.fixture +def dummy_config(): + """Return a fresh DummyConfig instance for tests to customize.""" + return DummyConfig() diff --git a/tests/temp_config.py b/tests/dummy_config.py similarity index 97% rename from tests/temp_config.py rename to tests/dummy_config.py index 80abbf1..eeaf3f7 100644 --- a/tests/temp_config.py +++ b/tests/dummy_config.py @@ -1,7 +1,7 @@ import os -class TempConfig: +class DummyConfig: """Small test configuration object used by unit and integration tests. Adjust attributes via environment variables where appropriate. diff --git a/tests/integration_full_workflow_test.py b/tests/integration_full_workflow_test.py index cbf2671..197f051 100644 --- a/tests/integration_full_workflow_test.py +++ b/tests/integration_full_workflow_test.py @@ -47,7 +47,7 @@ if bin_dir.exists(): print(f"Using local aria2c at: {aria2c_path}") from ytplaylist.downloader import PlaylistDownloader -from tests.temp_config import TempConfig +from tests.dummy_config import DummyConfig logging.basicConfig(level=logging.INFO, format='%(levelname)s:%(message)s') @@ -60,7 +60,7 @@ if not playlist_url: print(f"Using playlist URL: {playlist_url}") -cfg_base = TempConfig() +cfg_base = DummyConfig() # ensure yt-dlp exists import shutil as _sh @@ -76,7 +76,7 @@ root_tmp.mkdir(parents=True, exist_ok=True) failed = False for mode in MODES: print(f"\n=== Running mode: {mode} ===") - cfg = TempConfig() + cfg = DummyConfig() # Allow enabling verbose subprocess output from CI by setting YTPL_DEBUG=1 cfg.debug = bool(os.getenv("YTPL_DEBUG", "0") == "1") cfg.download_mode = mode diff --git a/tests/test_cli_flags.py b/tests/test_cli_flags.py index a149b3f..e0e8b16 100644 --- a/tests/test_cli_flags.py +++ b/tests/test_cli_flags.py @@ -1,23 +1,23 @@ import logging from ytplaylist.manager import PlaylistManager -from tests.temp_config import TempConfig +from tests.dummy_config import DummyConfig -class TestConfig(TempConfig): - playlists = [{"url": None, "save_path": "./tmp_test", "archive": "archive.txt"}] - -if __name__ == '__main__': +def test_run_with_prune_disabled(): logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(message)s") - print('--- Running with prune=False ---') - cfg=TestConfig() - m=PlaylistManager(cfg, debug=False) + cfg = DummyConfig() + cfg.playlists = [{"url": None, "save_path": "tests/tmp_test", "archive": "archive.txt"}] + m = PlaylistManager(cfg, debug=False) + # should complete without raising m.run() - print('Run complete prune=False') - print('\n--- Running with prune=True, non_interactive=True ---') - cfg2=TestConfig() - cfg2.prune=True - cfg2.non_interactive=True - m2=PlaylistManager(cfg2, debug=False) - m2.run() - print('Run complete prune=True non_interactive=True') + +def test_run_with_prune_enabled_non_interactive(): + logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(message)s") + cfg = DummyConfig() + cfg.playlists = [{"url": None, "save_path": "tests/tmp_test", "archive": "archive.txt"}] + cfg.prune = True + cfg.non_interactive = True + m = PlaylistManager(cfg, debug=False) + # should complete without raising + m.run() diff --git a/tests/test_config_loader_basic.py b/tests/test_config_loader_basic.py new file mode 100644 index 0000000..f37c91b --- /dev/null +++ b/tests/test_config_loader_basic.py @@ -0,0 +1,30 @@ +import json +import shutil +from pathlib import Path + +from ytplaylist.config import ConfigLoader + + +def test_config_loader_reads_properties(tmp_path, monkeypatch): + # create a minimal config file with known binary names that exist on PATH + cfg = { + "playlists": [{"url": "https://www.youtube.com/playlist?list=FAKE", "save_path": "./tmp", "archive": "archive.txt"}], + "yt_dlp_path": "python", + "ffmpeg_path": "python", + "aria2c_path": "python", + "max_parallel_downloads": 3, + "aria2c_connections": 2, + } + + p = tmp_path / "yt-playlist-config.json" + p.write_text(json.dumps(cfg), encoding="utf-8") + + # Use absolute path so ConfigLoader doesn't try to create ./config + loader = ConfigLoader(str(p)) + + assert loader.playlists == cfg["playlists"] + assert loader.yt_dlp_path == "python" + assert loader.ffmpeg_path == "python" + assert loader.aria2c_path == "python" + assert loader.max_parallel_downloads == 3 + assert loader.aria2c_connections == 2 diff --git a/tests/test_downloader_sanitize_and_path.py b/tests/test_downloader_sanitize_and_path.py new file mode 100644 index 0000000..cea8e05 --- /dev/null +++ b/tests/test_downloader_sanitize_and_path.py @@ -0,0 +1,24 @@ +from pathlib import Path + +from ytplaylist.downloader import PlaylistDownloader +from tests.dummy_config import DummyConfig + + +def test_sanitize_title_and_get_file_path(tmp_path): + cfg = DummyConfig() + playlist = {"url": None, "save_path": str(tmp_path)} + dl = PlaylistDownloader(cfg, playlist, 0) + + # illegal chars should be replaced and trimmed; fallback_id used when title becomes empty + title = ' My: <>:"/\\|?*Title ' + safe = dl.sanitize_title(title, "ABC123") + # ensure no illegal characters remain + assert all(c not in safe for c in dl.illegal_chars) + + # empty title should return fallback id + assert dl.sanitize_title(" ", "FALLBACK") == "FALLBACK" + + # get_file_path uses save_path and zero-padded index + path = dl.get_file_path(5, "SongName") + assert isinstance(path, Path) + assert path.name.startswith("005 - SongName") diff --git a/tests/test_manager_connection_warning.py b/tests/test_manager_connection_warning.py new file mode 100644 index 0000000..23e4748 --- /dev/null +++ b/tests/test_manager_connection_warning.py @@ -0,0 +1,26 @@ +import logging +from tests.dummy_config import DummyConfig +from ytplaylist.manager import PlaylistManager + + +def test_manager_warns_and_sleeps(monkeypatch, caplog): + # Avoid actually sleeping during the test + slept = {"called": False} + + def fake_sleep(sec): + slept["called"] = True + + # monkeypatch the sleep used inside the manager module + monkeypatch.setattr("ytplaylist.manager.time.sleep", fake_sleep) + + caplog.set_level(logging.WARNING) + cfg = DummyConfig() + cfg.max_parallel_downloads = 11 + cfg.aria2c_connections = 10 + cfg.playlists = [] + + m = PlaylistManager(cfg, debug=False) + m.run() + + assert slept["called"] is True + assert any("may overload your network" in rec.getMessage() for rec in caplog.records) diff --git a/tests/test_playlist_manager_run_behaviour.py b/tests/test_playlist_manager_run_behaviour.py new file mode 100644 index 0000000..e0e8b16 --- /dev/null +++ b/tests/test_playlist_manager_run_behaviour.py @@ -0,0 +1,23 @@ +import logging +from ytplaylist.manager import PlaylistManager +from tests.dummy_config import DummyConfig + + +def test_run_with_prune_disabled(): + logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(message)s") + cfg = DummyConfig() + cfg.playlists = [{"url": None, "save_path": "tests/tmp_test", "archive": "archive.txt"}] + m = PlaylistManager(cfg, debug=False) + # should complete without raising + m.run() + + +def test_run_with_prune_enabled_non_interactive(): + logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(message)s") + cfg = DummyConfig() + cfg.playlists = [{"url": None, "save_path": "tests/tmp_test", "archive": "archive.txt"}] + cfg.prune = True + cfg.non_interactive = True + m = PlaylistManager(cfg, debug=False) + # should complete without raising + m.run() diff --git a/tests/tmp_test/.gitkeep b/tests/tmp_test/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/tmp_test/archive.txt b/tests/tmp_test/archive.txt new file mode 100644 index 0000000..e69de29