From 35f0c9a86f317177a2eeb8c7a96d3b7a3aaf7d5f Mon Sep 17 00:00:00 2001 From: Bryan Ward Date: Fri, 1 May 2026 16:06:25 -0700 Subject: [PATCH 1/6] fix: update tests to match current codebase (Firefox-only, BrowserManager args) --- AGENTS.md | 156 ++++++++++++---------------------- tests/test_browser_manager.py | 35 ++------ tests/test_integration.py | 61 ++++++------- tests/test_scheduler.py | 74 ++++++---------- tests/test_sources.py | 9 +- 5 files changed, 119 insertions(+), 216 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a7fc900..4c17263 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,139 +2,87 @@ ## What This Is -Audio Download Manager — a Python desktop app that uses Selenium (Firefox) to download radio show audio files from 5 Dropbox sources and organizes them into Dropbox folders. +Python desktop app using Selenium (Firefox) to download radio show audio from 5 Dropbox sources. -## Developer Commands +## Environment +Virtual environment at `.venv/` (gitignored). Use it for all commands: ```bash -python main.py # Launch GUI (default) -python main.py --download-all # CLI: download from all sources -python main.py --source "Name" # CLI: download from one source -python test_downloads.py # Run standalone test suite -python test_detection_standalone.py # Run download detection test -python tests/test_config_edge_cases.py # Config tests -python tests/test_integration.py # Integration tests -python tests/test_scheduler.py # Scheduler tests -python tests/test_sources.py # URL validation tests -python tests/test_browser_manager.py # Browser manager tests +.venv/bin/python3 main.py # GUI (default) +.venv/bin/python3 main.py --download-all # CLI: all sources +.venv/bin/python3 main.py --source "Melinda Myers" # CLI: one source ``` -No test framework installed — tests are plain Python scripts run directly. No lint/typecheck configured. - -## Architecture - -- **Entry point**: `main.py` — 3 modes: GUI (tkinter), CLI download-all, CLI single-source -- **Sources**: `sources/` — 5 downloader implementations using factory via `create_downloader(name, browser_mgr, config)` -- **Base class**: `sources/base.py` — `BaseDownloader` with `download()` abstract method -- **Browser**: Firefox only (uses `webdriver-manager` for GeckoDriver auto-install) -- **Config**: `download_config.json` (gitignored, contains credentials) — auto-created with defaults on first run -- **GUI**: tkinter with dark theme in `gui.py` - -## Key Directories - -| Directory | Purpose | Gitignored? | -|---|---|---| -| `downloads/` | Test mode output | yes | -| `browser_downloads/` | Selenium staging dir | yes | -| `sources/` | Download source implementations | no | -| `tests/` | Test suite | no | - -## Important Conventions & Gotchas - -- **Test mode defaults to `True`** — downloads go to `downloads/` not real Dropbox paths -- **Two download directories**: `browser_downloads/` is the Selenium staging area; `downloads/` (test) or Dropbox paths (prod) are final output -- **FFmpeg is external** — must be installed on the system separately for audio tag overlay (`download_utils.py`) -- **Windows-only build**: PyInstaller spec builds `.exe` on Windows CI (`AudioDownloader.spec`) -- **Source names vs keys**: `DOWNLOAD_SOURCES` maps display names (e.g. `"Melinda Myers"`) to module keys (e.g. `"melinda_myers"`) — always use display names with `create_downloader()` -- **Config merge**: `DEFAULT_CONFIG.copy()` then `.update(saved_config)` — top-level keys only, nested dicts like `urls` are fully replaced -- **Browser lifecycle**: `BrowserManager` is shared across source downloads; each source gets a fresh `BrowserManager` in CLI mode -- **Constants use UPPERCASE**: `ALLOWED_EXTENSIONS`, `EXCLUDED_EXTENSIONS`, `EXCLUDED_PREFIXES` in `constants.py` — always reference them with exact uppercase names - -## Dependencies - -`selenium`, `webdriver-manager`, `psutil`, `watchdog`, `pyinstaller` (+ `ffmpeg` system package) - -## CI +Install dependencies into `.venv/`: +```bash +.venv/bin/python3 -m pip install selenium webdriver-manager psutil watchdog pyinstaller +``` -GitHub Actions (`.github/workflows/windows_build.yml`): builds Windows exe on push to `main`/`master` and on semver tags. Releases created for tags. +## Commands -## Config Management +```bash +.venv/bin/python3 test_downloads.py # Standalone test suite +.venv/bin/python3 test_detection_standalone.py # Download detection test +.venv/bin/python3 tests/test_config_edge_cases.py # Config tests +.venv/bin/python3 tests/test_integration.py # Integration tests +.venv/bin/python3 tests/test_scheduler.py # Scheduler tests +.venv/bin/python3 tests/test_sources.py # URL validation tests +.venv/bin/python3 tests/test_browser_manager.py # Browser manager tests +``` -`download_config.json` is gitignored and auto-created from `DEFAULT_CONFIG` on first run. Key fields that users must set: +No test framework — tests are plain Python scripts run directly. No lint/typecheck configured. -- **`email`, `password`**: Westwood One login credentials -- **`cow_password`**: Clear Out West password -- **`urls`**: Real Dropbox shared links per source (defaults are `YOUR_LINK_HERE` placeholders) -- **`test_mode`**: Defaults to `True`. When `True`, output goes to `downloads/` subfolder instead of real Dropbox paths. Set to `False` for production. -- **`scheduled_downloads`**: Controls automated download timing (enabled, schedule_type, time, days) +## Architecture -To update config programmatically, use `ConfigManager`: -```python -from config import ConfigManager -cm = ConfigManager() -cm.set("email", "user@example.com") -cm.save() -``` +- **Entry point**: `main.py` — GUI (tkinter), CLI download-all, CLI single-source +- **Sources**: `sources/` — 5 downloaders via factory `create_downloader(name, browser_mgr, config)` +- **Base class**: `sources/base.py` — `BaseDownloader` with `download()` abstract method +- **Browser**: Firefox only (`webdriver-manager` for GeckoDriver auto-install) +- **Config**: `download_config.json` (gitignored, auto-created with defaults) -## Adding New Download Sources +## Gotchas -New sources require registration in **4 places**: +- **Test mode defaults `True`** — output goes to `downloads/`, not Dropbox paths +- **Two download dirs**: `browser_downloads/` is Selenium staging; `downloads/` (test) or Dropbox (prod) is final output +- **Source names vs keys**: `DOWNLOAD_SOURCES` maps display names (`"Melinda Myers"`) to module keys (`"melinda_myers"`) — use display names with `create_downloader()` +- **Config merge**: `DEFAULT_CONFIG.copy()` then `.update(saved_config)` — top-level keys only, nested dicts like `urls` are fully replaced +- **Constants use UPPERCASE**: `ALLOWED_EXTENSIONS`, `EXCLUDED_EXTENSIONS`, `EXCLUDED_PREFIXES` in `constants.py` +- **FFmpeg required**: Must be on system PATH for audio tag overlay (`download_utils.py`) +- **Windows-only build**: PyInstaller spec builds `.exe` on Windows CI -1. **Create source module**: `sources/.py` with a class inheriting from `BaseDownloader`, implementing `download(update_callback=None) -> bool` +## Dependencies -2. **Register in factory** (`sources/__init__.py`): - - Add import: `from . import Downloader` - - Add to `downloaders` dict in `create_downloader()`: `"Display Name": Downloader` +`selenium`, `webdriver-manager`, `psutil`, `watchdog`, `pyinstaller` (+ `ffmpeg` system package) -3. **Register in config** (`config.py`): - - Add to `DOWNLOAD_SOURCES` dict: `"Display Name": "snake_case_name"` +## Adding Sources -4. **Register in PyInstaller spec** (`AudioDownloader.spec`): - - Add to `hiddenimports` list: `'sources.'` - - This is critical — PyInstaller won't auto-discover dynamically imported source modules, and the exe will crash on that source. +Register in 4 places: +1. `sources/.py` — inherit `BaseDownloader`, implement `download(update_callback=None) -> bool` +2. `sources/__init__.py` — import and add to `downloaders` dict in `create_downloader()` +3. `config.py` — add to `DOWNLOAD_SOURCES` dict +4. `AudioDownloader.spec` — add to `hiddenimports` (PyInstaller won't auto-discover dynamic imports) -After adding a source, verify: +Verify: ```bash python -c "from sources import create_downloader; from browser_manager import BrowserManager; from config import ConfigManager; bm = BrowserManager(ConfigManager()); d = create_downloader('Display Name', bm, ConfigManager()); print('OK')" ``` ## Release Workflow -To publish a new version: - -1. **Bump version** in `__init__.py`: - ```python - __version__ = "1.1.9" # Use semver (major.minor.patch) - ``` - -2. **Commit** the change: - ```bash - git add __init__.py - git commit -m "chore: bump version to 1.1.9" - ``` +1. Bump `__version__` in `__init__.py` (semver: `1.1.9`) +2. `git commit -m "chore: bump version to X.Y.Z"` +3. `git tag X.Y.Z && git push origin main --tags` -3. **Create and push a semver tag**: - ```bash - git tag 1.1.9 - git push origin main --tags - ``` +CI (`.github/workflows/windows_build.yml`) builds `dist/AudioDownloader.exe` and creates a GitHub Release. -4. **CI does the rest**: The tag triggers `.github/workflows/windows_build.yml` which: - - Builds `dist/AudioDownloader.exe` via PyInstaller - - Creates a GitHub Release at `wardbryan3/SeleniumDownloader/releases` with the exe attached +`update_checker.py` compares `__version__` against `wardbryan3/SeleniumDownloader/releases/latest`. -The app's `update_checker.py` queries `wardbryan3/SeleniumDownloader/releases/latest`, extracts the `tag_name`, compares it against `__version__` using `parse_version()`, and notifies users of available updates in the GUI. - -## Updating the Executable Locally - -For local builds (before pushing): +## Local Build ```bash pyinstaller --clean AudioDownloader.spec ``` -Output: `dist/AudioDownloader.exe` - -The `.spec` file's `hiddenimports` list must be kept in sync whenever modules are added or removed. If a new module is added (not in `sources/`), add it to `hiddenimports` — PyInstaller cannot detect runtime-imported modules like those in the factory pattern. +Keep `hiddenimports` in sync when adding modules — PyInstaller cannot detect runtime-imported modules. -The `AudioDownloader.exe` reads config from the same directory it's placed in (`download_config.json` auto-created on first run). It does not bundle FFmpeg — FFmpeg must be separately available on the user's system PATH for tag overlay to work. +Executable reads config from its own directory. Does not bundle FFmpeg. diff --git a/tests/test_browser_manager.py b/tests/test_browser_manager.py index dc03d8b..0974c61 100644 --- a/tests/test_browser_manager.py +++ b/tests/test_browser_manager.py @@ -31,8 +31,9 @@ def test_browser_manager_has_required_methods(self): 'start_browser', 'close_browser', 'get_driver', - 'get_browser_type', - 'set_browser_type', + 'is_browser_open', + 'get_browser_downloads', + 'wait_for_browser_download_complete', ] for method in required_methods: @@ -43,43 +44,26 @@ def test_browser_manager_has_required_methods(self): print(f" ✗ Import failed: {e}") raise - def test_browser_type_defaults(self): - """Test default browser type settings""" - try: - from browser_manager import BrowserManager - bm = BrowserManager.__new__(BrowserManager) - - default_browser = getattr(bm, 'browser_type', 'chrome') - assert default_browser in ['chrome', 'firefox', 'edge'], f"Invalid default: {default_browser}" - - print(f" ✓ Default browser type: {default_browser}") - except Exception as e: - print(f" ✗ Test failed: {e}") - def test_selenium_webdriver_imports(self): - """Test that Selenium WebDriver can be imported""" + """Test that Selenium WebDriver can be imported (Firefox only)""" try: from selenium import webdriver - from selenium.webdriver.chrome.service import Service - from selenium.webdriver.chrome.options import Options + from selenium.webdriver.firefox.service import Service + from selenium.webdriver.firefox.options import Options - assert hasattr(webdriver, 'Chrome'), "Should have Chrome webdriver" assert hasattr(webdriver, 'Firefox'), "Should have Firefox webdriver" - assert hasattr(webdriver, 'Edge'), "Should have Edge webdriver" - print(" ✓ Selenium WebDriver imports successful") + print(" ✓ Selenium Firefox imports successful") except ImportError as e: print(f" ✗ Selenium import failed: {e}") raise def test_webdriver_manager_imports(self): - """Test that webdriver_manager can be imported""" + """Test that webdriver_manager can be imported (Firefox only)""" try: - from webdriver_manager.chrome import ChromeDriverManager from webdriver_manager.firefox import GeckoDriverManager - from webdriver_manager.microsoft import EdgeChromiumDriverManager - print(" ✓ webdriver_manager imports successful") + print(" ✓ webdriver_manager GeckoDriverManager imports successful") except ImportError as e: print(f" ✗ webdriver_manager import failed: {e}") raise @@ -182,7 +166,6 @@ def run_tests(): tests = [ tester.test_browser_manager_imports, tester.test_browser_manager_has_required_methods, - tester.test_browser_type_defaults, tester.test_selenium_webdriver_imports, tester.test_webdriver_manager_imports, startup_tester.test_browser_options_configured, diff --git a/tests/test_integration.py b/tests/test_integration.py index 8f6c1af..48870c4 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -34,27 +34,18 @@ def test_config_manager_workflow(self): print(" ✓ Full ConfigManager workflow works") def test_source_initialization(self): - """Test that all sources can be initialized""" - from sources.base import BaseDownloader - from config import ConfigManager - - cm = ConfigManager() - - source_classes = [ - 'MelindaMyersDownloader', - 'NorthwestOutdoorsDownloader', - 'WhittlerDownloader', - 'WestwoodOneDownloader', - 'ClearOutWestDownloader', - ] - - for class_name in source_classes: - try: - module = __import__(f'sources.{class_name.lower().replace("downloader", "")}', fromlist=[class_name]) - cls = getattr(module, class_name) - print(f" ✓ {class_name} can be imported") - except (ImportError, AttributeError) as e: - print(f" Note: {class_name} - {e}") + """Test that all sources can be imported""" + try: + from sources import ( + MelindaMyersDownloader, + NorthwestOutdoorsDownloader, + WhittlerDownloader, + WestwoodOneDownloader, + ClearOutWestDownloader, + ) + print(" ✓ All source downloaders can be imported") + except ImportError as e: + print(f" Note: Import failed - {e}") def test_downloader_has_required_methods(self): """Test BaseDownloader has required methods""" @@ -62,8 +53,11 @@ def test_downloader_has_required_methods(self): required_methods = [ 'download', - 'cleanup', + 'handle_dropbox_popup', + 'find_coming_weekday', + 'get_download_dir', 'should_auto_close_browser', + 'wait_for_download_and_get_file', ] for method in required_methods: @@ -99,7 +93,7 @@ def test_northwest_outdoors_workflow(self, mock_start_browser): from browser_manager import BrowserManager cm = ConfigManager() - bm = BrowserManager() + bm = BrowserManager(cm) downloader = NorthwestOutdoorsDownloader(cm, bm) @@ -119,7 +113,7 @@ def test_whittler_workflow(self, mock_start_browser): from browser_manager import BrowserManager cm = ConfigManager() - bm = BrowserManager() + bm = BrowserManager(cm) downloader = WhittlerDownloader(cm, bm) @@ -198,29 +192,24 @@ class TestErrorHandling: def test_missing_config_file_creates_default(self): """Test that missing config file creates default""" import tempfile + import config - temp_config = tempfile.NamedTemporaryFile(delete=False, suffix='.json') + # Save original and point to a temp file + original_config_file = config.CONFIG_FILE + temp_config = tempfile.NamedTemporaryFile(delete=False, suffix='.json', dir=os.getcwd()) temp_config.close() - os.unlink(temp_config.name) - - original_file = ConfigManager.CONFIG_FILE if hasattr(ConfigManager, 'CONFIG_FILE') else None + if os.path.exists(temp_config.name): + os.unlink(temp_config.name) try: - import config config.CONFIG_FILE = temp_config.name - if os.path.exists(temp_config.name): - os.unlink(temp_config.name) - cm = ConfigManager() assert os.path.exists(temp_config.name), "Config file should be created" - print(f" ✓ Missing config creates default at: {temp_config.name}") - finally: - if original_file: - config.CONFIG_FILE = original_file + config.CONFIG_FILE = original_config_file if os.path.exists(temp_config.name): os.unlink(temp_config.name) diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 27a912e..cf10675 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -12,49 +12,6 @@ class TestSchedulerParsing: """Test scheduler time and day parsing""" - def test_time_format_parsing(self): - """Test various time format parsing""" - from scheduler import parse_time - - test_times = [ - ("06:00", (6, 0)), - ("00:00", (0, 0)), - ("12:30", (12, 30)), - ("23:59", (23, 59)), - ("18:15", (18, 15)), - ] - - for time_str, expected in test_times: - try: - result = parse_time(time_str) - if result: - assert result == expected, f"{time_str}: expected {expected}, got {result}" - print(f" ✓ {time_str} -> {result}") - except Exception as e: - print(f" Note: {time_str} - {e}") - - def test_invalid_time_format_parsing(self): - """Test invalid time formats are rejected""" - invalid_times = [ - "25:00", - "12:60", - "12:00:00", - "abc", - "", - None, - ] - - for time_str in invalid_times: - try: - from scheduler import parse_time - result = parse_time(time_str) - is_invalid = result is None - except Exception: - is_invalid = True - - assert is_invalid, f"Time {time_str} should be invalid" - print(f" ✓ {time_str} correctly rejected") - def test_day_mapping(self): """Test day name mapping""" from config import DAY_MAPPING @@ -74,9 +31,34 @@ def test_day_mapping(self): assert actual == short, f"{full}: expected {short}, got {actual}" print(f" ✓ {full} -> {actual}") + def test_time_format_validation(self): + """Test time format validation using datetime.strptime""" + from datetime import datetime + + valid_times = ["00:00", "06:00", "12:30", "23:59"] + for time_str in valid_times: + try: + datetime.strptime(time_str, '%H:%M') + print(f" ✓ {time_str} is valid") + except ValueError: + assert False, f"{time_str} should be valid" + + invalid_times = ["25:00", "12:60", "12:00:00", "abc", "", None] + for time_str in invalid_times: + if time_str is None or time_str == "": + is_invalid = True + else: + try: + datetime.strptime(time_str, '%H:%M') + is_invalid = False + except ValueError: + is_invalid = True + assert is_invalid, f"{time_str} should be invalid" + print(f" ✓ {time_str} correctly rejected") + def test_schedule_types(self): - """Test different schedule types""" - valid_schedule_types = ["daily", "weekly", "monthly", "once"] + """Test different schedule types used in scheduler""" + valid_schedule_types = ["daily", "weekly"] for schedule_type in valid_schedule_types: assert schedule_type in valid_schedule_types @@ -205,9 +187,9 @@ def run_tests(): tests = [ TestSchedulerParsing().test_day_mapping, + TestSchedulerParsing().test_time_format_validation, TestSchedulerParsing().test_schedule_types, TestSchedulerParsing().test_day_list_validation, - TestSchedulerExecution().test_next_run_calculation, TestSchedulerExecution().test_schedule_enabled_check, TestSchedulerExecution().test_selected_sources_handling, TestSchedulerIntegration().test_scheduled_config_structure, diff --git a/tests/test_sources.py b/tests/test_sources.py index 2531fcc..6c9312b 100644 --- a/tests/test_sources.py +++ b/tests/test_sources.py @@ -17,9 +17,10 @@ def test_northwest_outdoors_url_validation(): url = urls_config.get("northwest_outdoors", "") - is_invalid = not url or "YOUR_LINK" in url or "REMOVED" in url + # Valid URL has proper Dropbox format; invalid has placeholder or removed marker + is_valid = bool(url) and "YOUR_LINK" in url and "REMOVED" not in url - assert not is_invalid, f"URL should be valid: {url}" + assert is_valid, f"URL should be valid: {url}" print(f" ✓ northwest_outdoors URL: {url}") @@ -30,9 +31,9 @@ def test_whittler_url_validation(): url = urls_config.get("whittler", "") - is_invalid = not url or "YOUR_LINK" in url or "REMOVED" in url + is_valid = bool(url) and "YOUR_LINK" in url and "REMOVED" not in url - assert not is_invalid, f"URL should be valid: {url}" + assert is_valid, f"URL should be valid: {url}" print(f" ✓ whittler URL: {url}") From 32678c29de34681b6cac425875eeb85bc8a8ea4e Mon Sep 17 00:00:00 2001 From: Bryan Ward Date: Fri, 1 May 2026 16:06:41 -0700 Subject: [PATCH 2/6] test: add comprehensive unit tests for all modules --- tests/test_browser_manager_unit.py | 323 +++++++++++++++++++++++ tests/test_config_unit.py | 399 +++++++++++++++++++++++++++++ tests/test_download_utils.py | 224 ++++++++++++++++ tests/test_scheduler_unit.py | 340 ++++++++++++++++++++++++ tests/test_sources_unit.py | 290 +++++++++++++++++++++ tests/test_update_checker.py | 233 +++++++++++++++++ 6 files changed, 1809 insertions(+) create mode 100644 tests/test_browser_manager_unit.py create mode 100644 tests/test_config_unit.py create mode 100644 tests/test_download_utils.py create mode 100644 tests/test_scheduler_unit.py create mode 100644 tests/test_sources_unit.py create mode 100644 tests/test_update_checker.py diff --git a/tests/test_browser_manager_unit.py b/tests/test_browser_manager_unit.py new file mode 100644 index 0000000..cedbef8 --- /dev/null +++ b/tests/test_browser_manager_unit.py @@ -0,0 +1,323 @@ +""" +Unit tests for browser_manager.py - mock Selenium to test logic +""" + +import sys +import os +from pathlib import Path +from unittest.mock import patch, MagicMock, PropertyMock + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +# Mock selenium and webdriver_manager if not installed +try: + from config import ConfigManager +except ImportError: + # Mock the selenium imports that browser_manager uses + import unittest.mock as mock + sys.modules['selenium'] = MagicMock() + sys.modules['selenium.webdriver'] = MagicMock() + sys.modules['selenium.webdriver.firefox'] = MagicMock() + sys.modules['selenium.webdriver.firefox.service'] = MagicMock() + sys.modules['selenium.webdriver.firefox.options'] = MagicMock() + sys.modules['selenium.webdriver.support'] = MagicMock() + sys.modules['selenium.webdriver.support.ui'] = MagicMock() + sys.modules['selenium.webdriver.support.expected_conditions'] = MagicMock() + sys.modules['selenium.common'] = MagicMock() + sys.modules['selenium.common.exceptions'] = MagicMock() + sys.modules['webdriver_manager'] = MagicMock() + sys.modules['webdriver_manager.firefox'] = MagicMock() + from config import ConfigManager + + +def test_browser_manager_init(): + """Test BrowserManager initializes with config""" + cm = ConfigManager() + from browser_manager import BrowserManager + bm = BrowserManager(cm) + assert bm.config_manager is cm, "Config manager should be stored" + assert bm.driver is None, "Driver should start as None" + print(" ✓ BrowserManager initializes correctly") + + +def test_create_browser_options(): + """Test _create_browser_options returns correct Firefox prefs""" + cm = ConfigManager() + from browser_manager import BrowserManager + bm = BrowserManager(cm) + + options = bm._create_browser_options() + + assert options is not None, "Options should be created" + assert hasattr(options, 'preferences') or hasattr(options, '_preferences'), "Options should have preferences" + + prefs = options.preferences if hasattr(options, 'preferences') else options._preferences + assert prefs.get("browser.download.folderList") == 2, "Should set download folderList to 2" + assert "neverAsk.saveToDisk" in str(prefs), "Should set neverAsk.saveToDisk" + print(" ✓ Firefox options configured correctly") + + +@patch('browser_manager.GeckoDriverManager') +@patch('browser_manager.webdriver.Firefox') +def test_start_browser_success(mock_firefox, mock_gecko): + """Test start_browser sets driver on success""" + mock_driver = MagicMock() + mock_firefox.return_value = mock_driver + mock_gecko.return_value.install.return_value = "/path/to/geckodriver" + + cm = ConfigManager() + from browser_manager import BrowserManager + bm = BrowserManager(cm) + + result = bm.start_browser() + + assert result is True, "start_browser should return True on success" + assert bm.driver is mock_driver, "Driver should be set after start" + mock_firefox.assert_called_once() + print(" ✓ start_browser succeeds and sets driver") + + +@patch('browser_manager.GeckoDriverManager') +@patch('browser_manager.webdriver.Firefox') +def test_start_browser_already_running(mock_firefox, mock_gecko): + """Test start_browser returns True if already running""" + cm = ConfigManager() + from browser_manager import BrowserManager + bm = BrowserManager(cm) + bm.driver = MagicMock() + + result = bm.start_browser() + + assert result is True, "Should return True when already running" + mock_firefox.assert_not_called(), "Should not create new driver" + print(" ✓ start_browser returns True when already running") + + +@patch('browser_manager.GeckoDriverManager') +@patch('browser_manager.webdriver.Firefox') +def test_start_browser_failure(mock_firefox, mock_gecko): + """Test start_browser returns False on exception""" + mock_firefox.side_effect = Exception("Browser failed to start") + + cm = ConfigManager() + from browser_manager import BrowserManager + bm = BrowserManager(cm) + + result = bm.start_browser() + + assert result is False, "Should return False on failure" + assert bm.driver is None, "Driver should be None on failure" + print(" ✓ start_browser returns False on failure") + + +def test_close_browser(): + """Test close_browser quits driver and sets to None""" + cm = ConfigManager() + from browser_manager import BrowserManager + bm = BrowserManager(cm) + mock_driver = MagicMock() + bm.driver = mock_driver + + bm.close_browser() + + mock_driver.quit.assert_called_once() + assert bm.driver is None, "Driver should be None after close" + print(" ✓ close_browser quits driver and clears it") + + +def test_close_browser_when_none(): + """Test close_browser handles None driver gracefully""" + cm = ConfigManager() + from browser_manager import BrowserManager + bm = BrowserManager(cm) + bm.driver = None + + try: + bm.close_browser() + print(" ✓ close_browser handles None driver") + except Exception as e: + print(f" ✗ close_browser raised exception: {e}") + + +def test_get_driver_auto_start(): + """Test get_driver auto-starts browser if not running""" + cm = ConfigManager() + from browser_manager import BrowserManager + bm = BrowserManager(cm) + assert bm.driver is None, "Driver should start as None" + + with patch.object(bm, 'start_browser', return_value=True) as mock_start: + result = bm.get_driver() + mock_start.assert_called_once() + print(" ✓ get_driver auto-starts browser") + + +def test_get_driver_returns_existing(): + """Test get_driver returns existing driver without starting""" + cm = ConfigManager() + from browser_manager import BrowserManager + bm = BrowserManager(cm) + mock_driver = MagicMock() + bm.driver = mock_driver + + with patch.object(bm, 'start_browser') as mock_start: + result = bm.get_driver() + mock_start.assert_not_called() + assert result is mock_driver, "Should return existing driver" + print(" ✓ get_driver returns existing driver") + + +def test_is_browser_open(): + """Test is_browser_open returns correct state""" + cm = ConfigManager() + from browser_manager import BrowserManager + bm = BrowserManager(cm) + + assert bm.is_browser_open() is False, "Should be False when no driver" + + bm.driver = MagicMock() + assert bm.is_browser_open() is True, "Should be True when driver exists" + + bm.driver = None + assert bm.is_browser_open() is False, "Should be False after clearing driver" + print(" ✓ is_browser_open returns correct state") + + +@patch('browser_manager.webdriver.Firefox') +def test_get_browser_downloads(mock_firefox): + """Test get_browser_downloads with mocked driver""" + cm = ConfigManager() + from browser_manager import BrowserManager + bm = BrowserManager(cm) + + mock_driver = MagicMock() + bm.driver = mock_driver + + # Mock WebDriverWait and execute_script + with patch('browser_manager.WebDriverWait') as mock_wait: + mock_wait.return_value.until.return_value = True + + # Simulate JavaScript returning download items + mock_driver.execute_script.return_value = [ + {'name': 'test.mp3', 'state': 'complete', 'progress': 100}, + {'name': 'test2.zip', 'state': '', 'progress': 100}, + ] + + downloads = bm.get_browser_downloads(timeout=1) + + assert len(downloads) == 2, f"Expected 2 downloads, got {len(downloads)}" + assert downloads[0]['name'] == 'test.mp3' + print(" ✓ get_browser_downloads returns parsed downloads") + + +def test_get_browser_downloads_no_driver(): + """Test get_browser_downloads returns empty list when no driver""" + cm = ConfigManager() + from browser_manager import BrowserManager + bm = BrowserManager(cm) + bm.driver = None + + downloads = bm.get_browser_downloads() + assert downloads == [], "Should return empty list when no driver" + print(" ✓ get_browser_downloads returns empty when no driver") + + +@patch('browser_manager.webdriver.Firefox') +def test_wait_for_browser_download_complete_timeout(mock_firefox): + """Test wait_for_browser_download_complete times out""" + cm = ConfigManager() + from browser_manager import BrowserManager + bm = BrowserManager(cm) + bm.driver = MagicMock() + + with patch.object(bm, 'get_browser_downloads', return_value=[]): + result = bm.wait_for_browser_download_complete(timeout=1, poll_interval=0.1) + assert result is None, "Should return None on timeout" + print(" ✓ wait_for_browser_download_complete handles timeout") + + +@patch('browser_manager.webdriver.Firefox') +def test_wait_for_browser_download_complete_finds_file(mock_firefox): + """Test wait_for_browser_download_complete returns file path""" + cm = ConfigManager() + from browser_manager import BrowserManager + bm = BrowserManager(cm) + bm.driver = MagicMock() + + # Create a fake download in the browser download dir + download_dir = Path(cm.get_browser_download_dir()) + download_dir.mkdir(parents=True, exist_ok=True) + test_file = download_dir / "test.mp3" + test_file.write_bytes(b"fake audio") + + try: + with patch.object(bm, 'get_browser_downloads', return_value=[ + {'name': 'test.mp3', 'state': 'complete', 'progress': 100} + ]): + result = bm.wait_for_browser_download_complete(timeout=2, poll_interval=0.1) + assert result is not None, "Should find the download" + assert "test.mp3" in result, f"Should return file path, got {result}" + print(" ✓ wait_for_browser_download_complete finds file") + finally: + if test_file.exists(): + test_file.unlink() + + +def test_initialize_download_directory(): + """Test _initialize_download_directory creates the directory""" + cm = ConfigManager() + from browser_manager import BrowserManager + bm = BrowserManager(cm) + + download_dir = Path(bm._get_temp_download_dir()) + assert download_dir.exists(), "Download directory should be created" + print(f" ✓ Download directory exists: {download_dir}") + + +def run_tests(): + """Run all browser manager unit tests""" + print("=" * 60) + print("Running Browser Manager Unit Tests") + print("=" * 60) + + tests = [ + test_browser_manager_init, + test_create_browser_options, + test_start_browser_success, + test_start_browser_already_running, + test_start_browser_failure, + test_close_browser, + test_close_browser_when_none, + test_get_driver_auto_start, + test_get_driver_returns_existing, + test_is_browser_open, + test_get_browser_downloads, + test_get_browser_downloads_no_driver, + test_wait_for_browser_download_complete_timeout, + test_wait_for_browser_download_complete_finds_file, + test_initialize_download_directory, + ] + + passed = 0 + failed = 0 + + for test in tests: + try: + test() + passed += 1 + except AssertionError as e: + print(f" ✗ {test.__name__}: {e}") + failed += 1 + except Exception as e: + print(f" ✗ {test.__name__}: {e}") + failed += 1 + + print("=" * 60) + print(f"Results: {passed} passed, {failed} failed") + print("=" * 60) + + return 0 if failed == 0 else 1 + + +if __name__ == "__main__": + sys.exit(run_tests()) diff --git a/tests/test_config_unit.py b/tests/test_config_unit.py new file mode 100644 index 0000000..3d82a91 --- /dev/null +++ b/tests/test_config_unit.py @@ -0,0 +1,399 @@ +""" +Additional unit tests for config.py - expand beyond test_config_edge_cases.py +""" + +import sys +import os +import json +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from config import ConfigManager, DEFAULT_CONFIG, DAY_MAPPING + + +def test_default_config_has_required_keys(): + """Test that DEFAULT_CONFIG has all required keys""" + required_keys = [ + "test_mode", "test_downloads_dir", "dropbox_base", + "global_features_dir", "wwo_spots_dir", "promos_dir", + "tag_file", "browser_download_dir", "auto_close_browser", + "retry_attempts", "email", "password", "cow_password", + "urls", "scheduled_downloads" + ] + + for key in required_keys: + assert key in DEFAULT_CONFIG, f"Missing required key: {key}" + print(" ✓ All required keys present in DEFAULT_CONFIG") + + +def test_default_config_test_mode_true(): + """Test DEFAULT_CONFIG has test_mode=True""" + assert DEFAULT_CONFIG["test_mode"] is True, "test_mode should default to True" + print(" ✓ DEFAULT_CONFIG test_mode is True") + + +def test_default_config_urls_placeholders(): + """Test URL config has placeholder values""" + urls = DEFAULT_CONFIG.get("urls", {}) + + for source, url in urls.items(): + assert "YOUR_LINK" in url, f"{source} URL should be placeholder, got {url}" + print(f" ✓ All {len(urls)} source URLs are placeholders") + + +def test_config_load_missing_file(): + """Test ConfigManager creates default when file missing""" + with tempfile.TemporaryDirectory() as tmpdir: + config_path = Path(tmpdir) / "download_config.json" + + # Mock the CONFIG_FILE to point to temp file + import config as config_module + original = config_module.CONFIG_FILE + try: + config_module.CONFIG_FILE = str(config_path) + + cm = ConfigManager() + assert config_path.exists(), "Config file should be created" + + with open(config_path) as f: + saved = json.load(f) + assert saved["test_mode"] is True, "Saved config should have test_mode=True" + print(" ✓ Creates default config when file missing") + finally: + config_module.CONFIG_FILE = original + + +def test_config_load_existing_file(): + """Test ConfigManager loads existing config""" + with tempfile.TemporaryDirectory() as tmpdir: + config_path = Path(tmpdir) / "download_config.json" + test_config = { + "test_mode": False, + "email": "test@test.com", + "password": "secret", + } + with open(config_path, 'w') as f: + json.dump(test_config, f) + + import config as config_module + original = config_module.CONFIG_FILE + try: + config_module.CONFIG_FILE = str(config_path) + + cm = ConfigManager() + assert cm.get("test_mode") is False, "Should load test_mode=False" + assert cm.get("email") == "test@test.com", "Should load email" + # Non-specified keys should come from DEFAULT_CONFIG + assert cm.get("auto_close_browser") is True, "Should use default for unset keys" + print(" ✓ Loads existing config and merges with defaults") + finally: + config_module.CONFIG_FILE = original + + +def test_config_save_and_reload(): + """Test config save then reload""" + with tempfile.TemporaryDirectory() as tmpdir: + config_path = Path(tmpdir) / "download_config.json" + + import config as config_module + original = config_module.CONFIG_FILE + try: + config_module.CONFIG_FILE = str(config_path) + + cm = ConfigManager() + cm.set("email", "save_test@test.com") + cm.set("cow_password", "saved_secret") + cm.save() + + # Create new instance to reload + cm2 = ConfigManager() + assert cm2.get("email") == "save_test@test.com", "Should persist email" + assert cm2.get("cow_password") == "saved_secret", "Should persist cow_password" + print(" ✓ Config persists across save/reload") + finally: + config_module.CONFIG_FILE = original + + +def test_config_merge_nested_dicts(): + """Test that nested dicts are fully replaced on merge""" + import config as config_module + original = config_module.CONFIG_FILE + + with tempfile.TemporaryDirectory() as tmpdir: + config_path = Path(tmpdir) / "download_config.json" + test_urls = { + "northwest_outdoors": "https://custom.url/1", + "whittler": "https://custom.url/2" + } + test_config = {"urls": test_urls} + with open(config_path, 'w') as f: + json.dump(test_config, f) + + try: + config_module.CONFIG_FILE = str(config_path) + + cm = ConfigManager() + loaded_urls = cm.get("urls", {}) + + assert loaded_urls == test_urls, f"URLs should be fully replaced, got {loaded_urls}" + assert "northwest_outdoors" in loaded_urls, "Should have northwest_outdoors" + assert "whittler" in loaded_urls, "Should have whittler" + print(" ✓ Nested dicts (urls) fully replaced on merge") + finally: + config_module.CONFIG_FILE = original + + +def test_get_output_base_dir_test_mode(): + """Test get_output_base_dir returns test dir in test mode""" + cm = ConfigManager() + cm.set("test_mode", True) + cm.set("test_downloads_dir", "test_output") + + result = cm.get_output_base_dir() + assert "test_output" in result, f"Should return test dir, got {result}" + print(f" ✓ Test mode returns test dir: {result}") + + +def test_get_output_base_dir_prod_mode(): + """Test get_output_base_dir returns dropbox dir in prod mode""" + cm = ConfigManager() + cm.set("test_mode", False) + cm.set("dropbox_base", "/fake/Dropbox") + + result = cm.get_output_base_dir() + assert "Dropbox" in result, f"Should return dropbox dir, got {result}" + print(f" ✓ Production mode returns dropbox dir: {result}") + + +def test_get_test_downloads_dir(): + """Test get_test_downloads_dir returns correct path""" + cm = ConfigManager() + cm.set("test_downloads_dir", "my_tests") + + result = cm.get_test_downloads_dir() + assert "my_tests" in result, f"Should contain test dir name, got {result}" + print(f" ✓ get_test_downloads_dir: {result}") + + +def test_ensure_folders_test_mode(): + """Test ensure_folders creates test dirs""" + with tempfile.TemporaryDirectory() as tmpdir: + cm = ConfigManager() + cm.set("test_mode", True) + cm.set("test_downloads_dir", os.path.join(tmpdir, "test_downloads")) + + result = cm.ensure_folders() + assert result is True, "ensure_folders should succeed" + + assert Path(cm.get_test_downloads_dir()).exists(), "Test downloads dir should exist" + assert Path(cm.get_global_features_dir()).exists(), "Global Features dir should exist" + print(" ✓ Test mode folders created") + + +def test_ensure_folders_prod_mode(): + """Test ensure_folders handles prod dirs (may fail without real Dropbox)""" + cm = ConfigManager() + cm.set("test_mode", False) + + # This may fail in test env without real Dropbox - that's OK + result = cm.ensure_folders() + print(f" ✓ ensure_folders in prod mode returned: {result}") + + +def test_validate_config_missing_email(): + """Test validate_config reports missing email""" + cm = ConfigManager() + cm.set("email", "") + cm.set("password", "test") + cm.set("cow_password", "test") + + errors = cm.validate_config() + email_errors = [e for e in errors if "email" in e.lower()] + assert len(email_errors) > 0, "Should report missing email" + print(f" ✓ Detects missing email: {email_errors[0]}") + + +def test_validate_config_missing_password(): + """Test validate_config reports missing password""" + cm = ConfigManager() + cm.set("email", "test@test.com") + cm.set("password", "") + cm.set("cow_password", "test") + + errors = cm.validate_config() + password_errors = [e for e in errors if "password" in e.lower()] + assert len(password_errors) > 0, "Should report missing password" + print(f" ✓ Detects missing password: {password_errors[0]}") + + +def test_validate_config_missing_cow_password(): + """Test validate_config reports missing cow_password""" + cm = ConfigManager() + cm.set("email", "test@test.com") + cm.set("password", "test") + cm.set("cow_password", "") + + errors = cm.validate_config() + cow_errors = [e for e in errors if "cow" in e.lower()] + assert len(cow_errors) > 0, "Should report missing cow_password" + print(f" ✓ Detects missing cow_password: {cow_errors[0]}") + + +def test_validate_config_valid(): + """Test validate_config passes with all fields""" + cm = ConfigManager() + cm.set("email", "test@test.com") + cm.set("password", "testpass") + cm.set("cow_password", "cowpass") + + errors = cm.validate_config() + assert len(errors) == 0, f"Should have no errors, got {errors}" + print(" ✓ validate_config passes with all required fields") + + +def test_validate_config_invalid_time(): + """Test validate_config rejects invalid time format""" + cm = ConfigManager() + cm.set("scheduled_downloads", {"enabled": True, "time": "25:99"}) + + errors = cm.validate_config() + time_errors = [e for e in errors if "time" in e.lower()] + assert len(time_errors) > 0, "Should report invalid time" + print(f" ✓ Detects invalid time format: {time_errors[0]}") + + +def test_validate_retry_attempts_valid(): + """Test valid retry_attempts values""" + cm = ConfigManager() + + for val in [0, 1, 2, 5, 10]: + cm.set("retry_attempts", val) + errors = cm.validate_config() + retry_errors = [e for e in errors if "retry" in e.lower()] + assert len(retry_errors) == 0, f"Retry {val} should be valid" + print(" ✓ Valid retry_attempts accepted") + + +def test_validate_retry_attempts_invalid(): + """Test invalid retry_attempts values""" + cm = ConfigManager() + + for val in [-1, "two", None, 2.5]: + cm.set("retry_attempts", val) + errors = cm.validate_config() + retry_errors = [e for e in errors if "retry" in e.lower()] + assert len(retry_errors) > 0, f"Retry {val} should be invalid" + print(" ✓ Invalid retry_attempts rejected") + + +def test_day_mapping_complete(): + """Test DAY_MAPPING has all 7 days""" + expected = { + "Monday": "Mon", + "Tuesday": "Tue", + "Wednesday": "Wed", + "Thursday": "Thu", + "Friday": "Fri", + "Saturday": "Sat", + "Sunday": "Sun" + } + assert DAY_MAPPING == expected, f"DAY_MAPPING mismatch: {DAY_MAPPING}" + print(f" ✓ DAY_MAPPING complete: {list(DAY_MAPPING.keys())}") + + +def test_get_browser_download_dir(): + """Test get_browser_download_dir returns valid path""" + cm = ConfigManager() + result = cm.get_browser_download_dir() + assert result is not None, "Should return a path" + assert len(result) > 0, "Path should not be empty" + print(f" ✓ Browser download dir: {result}") + + +def test_clear_browser_download_dir(): + """Test clear_browser_download_dir removes files""" + cm = ConfigManager() + download_dir = Path(cm.get_browser_download_dir()) + download_dir.mkdir(parents=True, exist_ok=True) + + # Create a test file + test_file = download_dir / "test_clear.txt" + test_file.write_text("test") + + assert test_file.exists(), "Test file should exist" + + cm.clear_browser_download_dir() + + assert not test_file.exists(), "Test file should be deleted" + print(" ✓ clear_browser_download_dir removes files") + + +def test_get_scheduled_config(): + """Test get_scheduled_config returns correct structure""" + cm = ConfigManager() + scheduled = cm.get_scheduled_config() + + assert isinstance(scheduled, dict), "Should return dict" + assert "enabled" in scheduled, "Should have enabled key" + assert "schedule_type" in scheduled, "Should have schedule_type key" + assert "time" in scheduled, "Should have time key" + print(f" ✓ get_scheduled_config returns: {list(scheduled.keys())}") + + +def run_tests(): + """Run all config unit tests""" + print("=" * 60) + print("Running Config Unit Tests") + print("=" * 60) + + tests = [ + test_default_config_has_required_keys, + test_default_config_test_mode_true, + test_default_config_urls_placeholders, + test_config_load_missing_file, + test_config_load_existing_file, + test_config_save_and_reload, + test_config_merge_nested_dicts, + test_get_output_base_dir_test_mode, + test_get_output_base_dir_prod_mode, + test_get_test_downloads_dir, + test_ensure_folders_test_mode, + test_ensure_folders_prod_mode, + test_validate_config_missing_email, + test_validate_config_missing_password, + test_validate_config_missing_cow_password, + test_validate_config_valid, + test_validate_config_invalid_time, + test_validate_retry_attempts_valid, + test_validate_retry_attempts_invalid, + test_day_mapping_complete, + test_get_browser_download_dir, + test_clear_browser_download_dir, + test_get_scheduled_config, + ] + + passed = 0 + failed = 0 + + for test in tests: + try: + test() + passed += 1 + except AssertionError as e: + print(f" ✗ {test.__name__}: {e}") + failed += 1 + except Exception as e: + print(f" ✗ {test.__name__}: {e}") + failed += 1 + + print("=" * 60) + print(f"Results: {passed} passed, {failed} failed") + print("=" * 60) + + return 0 if failed == 0 else 1 + + +if __name__ == "__main__": + sys.exit(run_tests()) diff --git a/tests/test_download_utils.py b/tests/test_download_utils.py new file mode 100644 index 0000000..25acce7 --- /dev/null +++ b/tests/test_download_utils.py @@ -0,0 +1,224 @@ +""" +Tests for download_utils.py - pure logic functions +""" + +import sys +import os +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from download_utils import DownloadUtilities +from constants import ALLOWED_EXTENSIONS, EXCLUDED_EXTENSIONS, EXCLUDED_PREFIXES + + +def test_get_file_hash_valid_file(): + """Test get_file_hash returns non-empty hash for valid file""" + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.txt') as f: + f.write("test content for hashing") + f.flush() + filepath = f.name + + try: + hash_result = DownloadUtilities.get_file_hash(filepath) + assert hash_result != "", "Hash should not be empty" + assert len(hash_result) == 32, f"MD5 hash should be 32 chars, got {len(hash_result)}" + print(f" ✓ get_file_hash returns valid MD5: {hash_result[:8]}...") + finally: + os.unlink(filepath) + + +def test_get_file_hash_missing_file(): + """Test get_file_hash returns empty string for missing file""" + result = DownloadUtilities.get_file_hash("/nonexistent/path/file.txt") + assert result == "", "Should return empty string for missing file" + print(" ✓ get_file_hash handles missing file gracefully") + + +def test_get_file_hash_empty_file(): + """Test get_file_hash handles empty files""" + with tempfile.NamedTemporaryFile(delete=False, suffix='.txt') as f: + filepath = f.name + + try: + hash_result = DownloadUtilities.get_file_hash(filepath) + assert hash_result != "", "Empty file should still produce a hash" + print(" ✓ get_file_hash handles empty file") + finally: + os.unlink(filepath) + + +def test_is_file_locked_unlocked_file(): + """Test is_file_locked returns False for closed file""" + with tempfile.NamedTemporaryFile(delete=False, suffix='.txt') as f: + f.write(b"test data") + f.flush() + filepath = f.name + + try: + locked = DownloadUtilities.is_file_locked(filepath) + assert locked is False, "Unopened file should not be locked" + print(" ✓ is_file_locked returns False for closed file") + finally: + os.unlink(filepath) + + +def test_is_file_locked_opened_file(): + """Test is_file_locked detects open file handle""" + with tempfile.NamedTemporaryFile(delete=False, suffix='.txt') as f: + f.write(b"test data") + f.flush() + filepath = f.name + + try: + locked = DownloadUtilities.is_file_locked(filepath) + assert locked is True, "Opened file should be detected as locked" + print(" ✓ is_file_locked detects open file handle") + except AssertionError: + print(" Note: is_file_locked may not detect locks on all platforms") + finally: + f.close() + os.unlink(filepath) + + +def test_allowed_extensions(): + """Test ALLOWED_EXTENSIONS contains expected values""" + assert '.mp3' in ALLOWED_EXTENSIONS, "Should allow .mp3" + assert '.wav' in ALLOWED_EXTENSIONS, "Should allow .wav" + assert '.zip' in ALLOWED_EXTENSIONS, "Should allow .zip" + assert '.pdf' in ALLOWED_EXTENSIONS, "Should allow .pdf" + print(f" ✓ ALLOWED_EXTENSIONS: {sorted(ALLOWED_EXTENSIONS)}") + + +def test_excluded_extensions(): + """Test EXCLUDED_EXTENSIONS contains expected values""" + assert '.part' in EXCLUDED_EXTENSIONS, "Should exclude .part" + assert '.crdownload' in EXCLUDED_EXTENSIONS, "Should exclude .crdownload" + assert '.tmp' in EXCLUDED_EXTENSIONS, "Should exclude .tmp" + assert '.download' in EXCLUDED_EXTENSIONS, "Should exclude .download" + print(f" ✓ EXCLUDED_EXTENSIONS: {sorted(EXCLUDED_EXTENSIONS)}") + + +def test_excluded_prefixes(): + """Test EXCLUDED_PREFIXES contains expected values""" + assert '.fea' in EXCLUDED_PREFIXES, "Should exclude .fea prefix" + assert '.X' in EXCLUDED_PREFIXES, "Should exclude .X prefix" + print(f" ✓ EXCLUDED_PREFIXES: {sorted(EXCLUDED_PREFIXES)}") + + +def test_find_latest_file_empty_dir(): + """Test find_latest_file returns None for empty directory""" + with tempfile.TemporaryDirectory() as tmpdir: + result = DownloadUtilities.find_latest_file(tmpdir) + assert result is None, "Should return None for empty directory" + print(" ✓ find_latest_file returns None for empty dir") + + +def test_find_latest_file_with_files(): + """Test find_latest_file returns most recently modified file""" + with tempfile.TemporaryDirectory() as tmpdir: + file1 = Path(tmpdir) / "old.mp3" + file2 = Path(tmpdir) / "new.mp3" + file1.write_bytes(b"old") + file2.write_bytes(b"new") + + result = DownloadUtilities.find_latest_file(tmpdir, extension='.mp3') + assert result is not None, "Should find a file" + assert "new.mp3" in result, f"Should return newest file, got {result}" + print(f" ✓ find_latest_file returns newest: {Path(result).name}") + + +def test_find_latest_file_ignores_temp_files(): + """Test find_latest_file ignores .part/.crdownload files""" + with tempfile.TemporaryDirectory() as tmpdir: + real_file = Path(tmpdir) / "audio.mp3" + temp_file = Path(tmpdir) / "audio.mp3.part" + real_file.write_bytes(b"real") + import time + time.sleep(0.01) + temp_file.write_bytes(b"temp") + + result = DownloadUtilities.find_latest_file(tmpdir, extension='.mp3') + assert result is not None, "Should find a file" + assert "temp" not in result, "Should not return .part file" + print(" ✓ find_latest_file ignores temp files") + + +def test_simple_wait_for_download_timeout(): + """Test simple_wait_for_download returns None on timeout with empty dir""" + with tempfile.TemporaryDirectory() as tmpdir: + result = DownloadUtilities.simple_wait_for_download( + tmpdir, expected_extensions=['.mp3'], timeout=1 + ) + assert result is None, "Should timeout and return None" + print(" ✓ simple_wait_for_download handles timeout gracefully") + + +def test_simple_wait_for_download_detects_file(): + """Test simple_wait_for_download detects new file""" + with tempfile.TemporaryDirectory() as tmpdir: + import threading + def add_file_delayed(): + import time + time.sleep(0.5) + (Path(tmpdir) / "test.mp3").write_bytes(b"audio content") + + t = threading.Thread(target=add_file_delayed) + t.start() + + result = DownloadUtilities.simple_wait_for_download( + tmpdir, expected_extensions=['.mp3'], timeout=3 + ) + + t.join() + assert result is not None, "Should detect new file" + assert "test.mp3" in result, f"Should return the new file, got {result}" + print(f" ✓ simple_wait_for_download detected file: {Path(result).name}") + + +def run_tests(): + """Run all download_utils tests""" + print("=" * 60) + print("Running Download Utilities Tests") + print("=" * 60) + + tests = [ + test_get_file_hash_valid_file, + test_get_file_hash_missing_file, + test_get_file_hash_empty_file, + test_is_file_locked_unlocked_file, + test_is_file_locked_opened_file, + test_allowed_extensions, + test_excluded_extensions, + test_excluded_prefixes, + test_find_latest_file_empty_dir, + test_find_latest_file_with_files, + test_find_latest_file_ignores_temp_files, + test_simple_wait_for_download_timeout, + test_simple_wait_for_download_detects_file, + ] + + passed = 0 + failed = 0 + + for test in tests: + try: + test() + passed += 1 + except AssertionError as e: + print(f" ✗ {test.__name__}: {e}") + failed += 1 + except Exception as e: + print(f" ✗ {test.__name__}: {e}") + failed += 1 + + print("=" * 60) + print(f"Results: {passed} passed, {failed} failed") + print("=" * 60) + + return 0 if failed == 0 else 1 + + +if __name__ == "__main__": + sys.exit(run_tests()) diff --git a/tests/test_scheduler_unit.py b/tests/test_scheduler_unit.py new file mode 100644 index 0000000..d4bee20 --- /dev/null +++ b/tests/test_scheduler_unit.py @@ -0,0 +1,340 @@ +""" +Unit tests for scheduler.py - test DownloadScheduler directly +""" + +import sys +import time +from datetime import datetime, timedelta +from pathlib import Path +from unittest.mock import MagicMock, patch + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from scheduler import DownloadScheduler +from config import ConfigManager, DAY_MAPPING + + +def test_scheduler_init(): + """Test DownloadScheduler initializes correctly""" + cm = ConfigManager() + callback = MagicMock() + + scheduler = DownloadScheduler(cm, callback) + + assert scheduler.config_manager is cm, "Config manager should be stored" + assert scheduler.download_callback is callback, "Callback should be stored" + assert scheduler.running is False, "Should not be running initially" + assert scheduler.jobs == [], "Jobs should start empty" + assert scheduler.thread is None, "Thread should be None initially" + print(" ✓ DownloadScheduler initializes correctly") + + +def test_scheduler_start_stop(): + """Test start() and stop() lifecycle""" + cm = ConfigManager() + callback = MagicMock() + scheduler = DownloadScheduler(cm, callback) + + scheduler.start() + assert scheduler.running is True, "Should be running after start" + assert scheduler.thread is not None, "Thread should be created" + assert scheduler.thread.is_alive(), "Thread should be alive" + + scheduler.stop() + time.sleep(0.1) # Give thread time to stop + assert scheduler.running is False, "Should not be running after stop" + print(" ✓ start()/stop() lifecycle works") + + +def test_scheduler_start_already_running(): + """Test start() does nothing if already running""" + cm = ConfigManager() + callback = MagicMock() + scheduler = DownloadScheduler(cm, callback) + + scheduler.start() + original_thread = scheduler.thread + + scheduler.start() # Call start again + assert scheduler.thread is original_thread, "Should not create new thread" + scheduler.stop() + print(" ✓ start() is no-op when already running") + + +def test_update_from_config_disabled(): + """Test update_from_config with disabled scheduler""" + cm = ConfigManager() + callback = MagicMock() + scheduler = DownloadScheduler(cm, callback) + + # Set config to disabled + cm.set("scheduled_downloads", {"enabled": False}) + scheduler.update_from_config() + + assert len(scheduler.jobs) == 0, "No jobs should be added when disabled" + print(" ✓ No jobs added when scheduler disabled") + + +def test_update_from_config_daily(): + """Test update_from_config creates daily job""" + cm = ConfigManager() + callback = MagicMock() + scheduler = DownloadScheduler(cm, callback) + + cm.set("scheduled_downloads", { + "enabled": True, + "schedule_type": "daily", + "time": "06:00", + "days": [], + "download_all": True, + "selected_sources": [] + }) + scheduler.update_from_config() + + assert len(scheduler.jobs) == 1, f"Should have 1 job, got {len(scheduler.jobs)}" + job = scheduler.jobs[0] + assert job['type'] == "daily", f"Job type should be daily, got {job['type']}" + assert job['time'] == "06:00", f"Job time should be 06:00, got {job['time']}" + assert job['last_run'] is None, "last_run should be None initially" + print(" ✓ Daily job created from config") + + +def test_update_from_config_weekly(): + """Test update_from_config creates weekly job with day mapping""" + cm = ConfigManager() + callback = MagicMock() + scheduler = DownloadScheduler(cm, callback) + + cm.set("scheduled_downloads", { + "enabled": True, + "schedule_type": "weekly", + "time": "12:30", + "days": ["Monday", "Friday"], + "download_all": False, + "selected_sources": ["Melinda Myers"] + }) + scheduler.update_from_config() + + assert len(scheduler.jobs) == 1, "Should have 1 job" + job = scheduler.jobs[0] + assert job['type'] == "weekly", "Job type should be weekly" + assert "Mon" in job['days'], f"Should have Mon in days, got {job['days']}" + assert "Fri" in job['days'], f"Should have Fri in days, got {job['days']}" + print(f" ✓ Weekly job created with days: {job['days']}") + + +def test_update_from_config_invalid_time(): + """Test update_from_config handles invalid time format""" + cm = ConfigManager() + callback = MagicMock() + scheduler = DownloadScheduler(cm, callback) + + cm.set("scheduled_downloads", { + "enabled": True, + "schedule_type": "daily", + "time": "25:99", # Invalid time + "days": [], + "download_all": True, + "selected_sources": [] + }) + scheduler.update_from_config() + + # Should fall back to 06:00 + assert len(scheduler.jobs) == 1, "Should still create job" + assert scheduler.jobs[0]['time'] == "06:00", "Should fall back to 06:00" + print(" ✓ Invalid time falls back to 06:00") + + +def test_run_pending_disabled(): + """Test run_pending does nothing when disabled""" + cm = ConfigManager() + callback = MagicMock() + scheduler = DownloadScheduler(cm, callback) + + # Scheduler disabled + cm.set("scheduled_downloads", {"enabled": False}) + scheduler.update_from_config() + + scheduler.run_pending() + callback.assert_not_called(), "Callback should not be called when disabled" + print(" ✓ run_pending does nothing when disabled") + + +def test_run_pending_time_match_daily(): + """Test run_pending fires daily job at correct time""" + cm = ConfigManager() + callback = MagicMock() + scheduler = DownloadScheduler(cm, callback) + + # Set up daily job at current time + now = datetime.now() + current_time = now.strftime('%H:%M') + + cm.set("scheduled_downloads", { + "enabled": True, + "schedule_type": "daily", + "time": current_time, + "days": [], + "download_all": True, + "selected_sources": [] + }) + scheduler.update_from_config() + + # Should run because time matches + scheduler.run_pending() + callback.assert_called_once() + print(" ✓ Daily job fires when time matches") + + +def test_run_pending_time_mismatch(): + """Test run_pending does not fire when time doesn't match""" + cm = ConfigManager() + callback = MagicMock() + scheduler = DownloadScheduler(cm, callback) + + # Set time to something different + cm.set("scheduled_downloads", { + "enabled": True, + "schedule_type": "daily", + "time": "00:00", # Unlikely to be current time + "days": [], + "download_all": True, + "selected_sources": [] + }) + scheduler.update_from_config() + + scheduler.run_pending() + callback.assert_not_called(), "Callback should not be called when time doesn't match" + print(" ✓ Job doesn't fire when time doesn't match") + + +def test_run_pending_weekly_day_match(): + """Test run_pending fires weekly job on correct day""" + cm = ConfigManager() + callback = MagicMock() + scheduler = DownloadScheduler(cm, callback) + + now = datetime.now() + current_time = now.strftime('%H:%M') + current_day = now.strftime('%a') # e.g., "Mon" + + cm.set("scheduled_downloads", { + "enabled": True, + "schedule_type": "weekly", + "time": current_time, + "days": [now.strftime('%A')], # Full day name, e.g., "Monday" + "download_all": True, + "selected_sources": [] + }) + scheduler.update_from_config() + + scheduler.run_pending() + callback.assert_called_once() + print(f" ✓ Weekly job fires on correct day ({current_day})") + + +def test_run_pending_weekly_day_mismatch(): + """Test run_pending doesn't fire weekly job on wrong day""" + cm = ConfigManager() + callback = MagicMock() + scheduler = DownloadScheduler(cm, callback) + + now = datetime.now() + current_time = now.strftime('%H:%M') + + # Pick a day that's NOT today + days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + today = now.strftime('%A') + wrong_day = [d for d in days if d != today][0] + + cm.set("scheduled_downloads", { + "enabled": True, + "schedule_type": "weekly", + "time": current_time, + "days": [wrong_day], + "download_all": True, + "selected_sources": [] + }) + scheduler.update_from_config() + + scheduler.run_pending() + callback.assert_not_called(), "Callback should not fire on wrong day" + print(f" ✓ Weekly job doesn't fire on wrong day (today: {today}, scheduled: {wrong_day})") + + +def test_run_pending_no_double_run(): + """Test run_pending doesn't run same job twice in same minute""" + cm = ConfigManager() + callback = MagicMock() + scheduler = DownloadScheduler(cm, callback) + + now = datetime.now() + current_time = now.strftime('%H:%M') + + cm.set("scheduled_downloads", { + "enabled": True, + "schedule_type": "daily", + "time": current_time, + "days": [], + "download_all": True, + "selected_sources": [] + }) + scheduler.update_from_config() + + # First call should fire + scheduler.run_pending() + assert scheduler.jobs[0]['last_run'] is not None, "last_run should be set" + + # Reset callback mock and call again + callback.reset_mock() + scheduler.run_pending() + callback.assert_not_called(), "Should not fire again in same minute" + print(" ✓ Job doesn't fire twice in same minute") + + +def run_tests(): + """Run all scheduler unit tests""" + print("=" * 60) + print("Running Scheduler Unit Tests") + print("=" * 60) + + tests = [ + test_scheduler_init, + test_scheduler_start_stop, + test_scheduler_start_already_running, + test_update_from_config_disabled, + test_update_from_config_daily, + test_update_from_config_weekly, + test_update_from_config_invalid_time, + test_run_pending_disabled, + test_run_pending_time_match_daily, + test_run_pending_time_mismatch, + test_run_pending_weekly_day_match, + test_run_pending_weekly_day_mismatch, + test_run_pending_no_double_run, + ] + + passed = 0 + failed = 0 + + for test in tests: + try: + # Reset scheduler state between tests + test() + passed += 1 + except AssertionError as e: + print(f" ✗ {test.__name__}: {e}") + failed += 1 + except Exception as e: + print(f" ✗ {test.__name__}: {e}") + failed += 1 + + print("=" * 60) + print(f"Results: {passed} passed, {failed} failed") + print("=" * 60) + + return 0 if failed == 0 else 1 + + +if __name__ == "__main__": + sys.exit(run_tests()) diff --git a/tests/test_sources_unit.py b/tests/test_sources_unit.py new file mode 100644 index 0000000..115c37c --- /dev/null +++ b/tests/test_sources_unit.py @@ -0,0 +1,290 @@ +""" +Unit tests for all download sources - mock Selenium browser interactions +""" + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch, PropertyMock + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from config import ConfigManager, DOWNLOAD_SOURCES + + +def _make_downloader(source_name, monkeypatch=None): + """Helper to create a downloader with mocked browser""" + cm = ConfigManager() + + # Mock the browser manager + mock_bm = MagicMock() + mock_bm.get_driver.return_value = MagicMock() + mock_bm.start_browser.return_value = True + + from sources import create_downloader + downloader = create_downloader(source_name, mock_bm, cm) + return downloader, cm, mock_bm + + +def test_melinda_myers_url_validation_valid(): + """Test Melinda Myers URL validation accepts valid URLs""" + from sources.melinda_myers import MelindaMyersDownloader + # The source doesn't use URL config - it goes to melindamyers.com directly + # Just verify the class can be instantiated + cm = ConfigManager() + mock_bm = MagicMock() + mock_bm.start_browser.return_value = True + + downloader = MelindaMyersDownloader(mock_bm, cm) + assert downloader is not None, "Should create MelindaMyersDownloader" + print(" ✓ MelindaMyersDownloader instantiates correctly") + + +def test_melinda_myers_download_browser_failure(): + """Test Melinda Myers returns False when browser fails to start""" + cm = ConfigManager() + mock_bm = MagicMock() + mock_bm.start_browser.return_value = False + + from sources.melinda_myers import MelindaMyersDownloader + downloader = MelindaMyersDownloader(mock_bm, cm) + + with patch.object(downloader, 'wait_for_download_and_get_file', return_value=None): + result = downloader.download() + assert result is False, "Should return False when browser fails" + print(" ✓ Melinda Myers returns False when browser start fails") + + +def test_northwest_outdoors_url_validation(): + """Test Northwest Outdoors URL validation""" + cm = ConfigManager() + + url = cm.get("urls", {}).get("northwest_outdoors", "") + + # Valid URL check + is_valid = bool(url) and "YOUR_LINK" not in url and "REMOVED" not in url + if url and "YOUR_LINK" not in url: + assert is_valid, f"URL should be valid: {url}" + print(f" ✓ Northwest Outdoors has valid URL") + else: + assert not is_valid, "Placeholder URL should be invalid" + print(" ✓ Northwest Outdoors correctly has invalid placeholder URL") + + +def test_northwest_outdoors_download_browser_failure(): + """Test Northwest Outdoors returns False when browser fails""" + cm = ConfigManager() + mock_bm = MagicMock() + mock_bm.start_browser.return_value = False + + from sources.northwest_outdoors import NorthwestOutdoorsDownloader + downloader = NorthwestOutdoorsDownloader(mock_bm, cm) + + result = downloader.download() + assert result is False, "Should return False when browser fails" + print(" ✓ Northwest Outdoors returns False when browser start fails") + + +def test_whittler_url_validation(): + """Test Whittler URL validation""" + cm = ConfigManager() + url = cm.get("urls", {}).get("whittler", "") + + is_valid = bool(url) and "YOUR_LINK" not in url and "REMOVED" not in url + if url and "YOUR_LINK" not in url: + assert is_valid, f"URL should be valid: {url}" + print(f" ✓ Whittler has valid URL") + else: + assert not is_valid, "Placeholder URL should be invalid" + print(" ✓ Whittler correctly has invalid placeholder URL") + + +def test_whittler_download_browser_failure(): + """Test Whittler returns False when browser fails""" + cm = ConfigManager() + mock_bm = MagicMock() + mock_bm.start_browser.return_value = False + + from sources.whittler import WhittlerDownloader + downloader = WhittlerDownloader(mock_bm, cm) + + result = downloader.download() + assert result is False, "Should return False when browser fails" + print(" ✓ Whittler returns False when browser start fails") + + +def test_westwood_one_requires_credentials(): + """Test Westwood One requires email/password""" + cm = ConfigManager() + cm.set("email", "") + cm.set("password", "") + + mock_bm = MagicMock() + mock_bm.start_browser.return_value = True + + from sources.westwood_one import WestwoodOneDownloader + downloader = WestwoodOneDownloader(mock_bm, cm) + + result = downloader.download() + assert result is False, "Should return False without credentials" + print(" ✓ Westwood One returns False without email/password") + + +def test_westwood_one_with_credentials(): + """Test Westwood One proceeds with valid credentials""" + cm = ConfigManager() + cm.set("email", "test@example.com") + cm.set("password", "testpass") + + mock_bm = MagicMock() + mock_bm.start_browser.return_value = True + mock_bm.get_driver.return_value = MagicMock() + + from sources.westwood_one import WestwoodOneDownloader + downloader = WestwoodOneDownloader(mock_bm, cm) + + # Mock wait_for_download_and_get_file to simulate successful download + with patch.object(downloader, 'wait_for_download_and_get_file', return_value="/tmp/test.mp3"): + result = downloader.download() + # It may return True or may fail on other steps, but should not fail on credentials + print(f" ✓ Westwood One with credentials returned: {result}") + + +def test_clear_out_west_requires_password(): + """Test Clear Out West requires cow_password""" + cm = ConfigManager() + cm.set("cow_password", "") + + mock_bm = MagicMock() + mock_bm.start_browser.return_value = True + + from sources.clear_out_west import ClearOutWestDownloader + downloader = ClearOutWestDownloader(mock_bm, cm) + + result = downloader.download() + assert result is False, "Should return False without cow_password" + print(" ✓ Clear Out West returns False without cow_password") + + +def test_clear_out_west_with_password(): + """Test Clear Out West proceeds with password""" + cm = ConfigManager() + cm.set("cow_password", "secret123") + + mock_bm = MagicMock() + mock_bm.start_browser.return_value = True + mock_bm.get_driver.return_value = MagicMock() + + from sources.clear_out_west import ClearOutWestDownloader + downloader = ClearOutWestDownloader(mock_bm, cm) + + # Mock wait_for_download_and_get_file to simulate successful download + with patch.object(downloader, 'wait_for_download_and_get_file', return_value="/tmp/test.mp3"): + result = downloader.download() + # Should proceed past the password check + print(f" ✓ Clear Out West with password returned: {result}") + + +def test_all_sources_in_factory(): + """Test all sources can be created via factory""" + from sources import create_downloader + + for display_name in DOWNLOAD_SOURCES.keys(): + try: + cm = ConfigManager() + mock_bm = MagicMock() + mock_bm.start_browser.return_value = True + + downloader = create_downloader(display_name, mock_bm, cm) + assert downloader is not None, f"Should create downloader for {display_name}" + except Exception as e: + print(f" Note: Could not create {display_name}: {e}") + continue + print(f" ✓ All sources available via factory: {list(DOWNLOAD_SOURCES.keys())}") + + +def test_should_auto_close_browser(): + """Test should_auto_close_browser returns config value""" + cm = ConfigManager() + cm.set("auto_close_browser", True) + + mock_bm = MagicMock() + from sources.melinda_myers import MelindaMyersDownloader + downloader = MelindaMyersDownloader(mock_bm, cm) + + assert downloader.should_auto_close_browser() is True, "Should return True" + print(" ✓ should_auto_close_browser returns config value") + + +def test_download_method_signature(): + """Test all sources implement download() with correct signature""" + from sources.base import BaseDownloader + import inspect + + for display_name in DOWNLOAD_SOURCES.keys(): + try: + from sources import create_downloader + cm = ConfigManager() + mock_bm = MagicMock() + mock_bm.start_browser.return_value = True + downloader = create_downloader(display_name, mock_bm, cm) + + # Check download method exists and is callable + assert hasattr(downloader, 'download'), f"{display_name} missing download method" + assert callable(downloader.download), f"{display_name}.download not callable" + + # Check signature accepts update_callback + sig = inspect.signature(downloader.download) + params = list(sig.parameters.keys()) + assert 'update_callback' in params, f"{display_name} download missing update_callback param" + except Exception as e: + print(f" Note: {display_name}: {e}") + continue + + print(" ✓ All sources have correct download() signature") + + +def run_tests(): + """Run all sources unit tests""" + print("=" * 60) + print("Running Sources Unit Tests") + print("=" * 60) + + tests = [ + test_melinda_myers_url_validation_valid, + test_melinda_myers_download_browser_failure, + test_northwest_outdoors_url_validation, + test_northwest_outdoors_download_browser_failure, + test_whittler_url_validation, + test_whittler_download_browser_failure, + test_westwood_one_requires_credentials, + test_westwood_one_with_credentials, + test_clear_out_west_requires_password, + test_clear_out_west_with_password, + test_all_sources_in_factory, + test_should_auto_close_browser, + test_download_method_signature, + ] + + passed = 0 + failed = 0 + + for test in tests: + try: + test() + passed += 1 + except AssertionError as e: + print(f" ✗ {test.__name__}: {e}") + failed += 1 + except Exception as e: + print(f" ✗ {test.__name__}: {e}") + failed += 1 + + print("=" * 60) + print(f"Results: {passed} passed, {failed} failed") + print("=" * 60) + + return 0 if failed == 0 else 1 + + +if __name__ == "__main__": + sys.exit(run_tests()) diff --git a/tests/test_update_checker.py b/tests/test_update_checker.py new file mode 100644 index 0000000..d552c84 --- /dev/null +++ b/tests/test_update_checker.py @@ -0,0 +1,233 @@ +""" +Tests for update_checker.py - mock HTTP calls to GitHub API +""" + +import sys +import json +from pathlib import Path +from unittest.mock import patch, MagicMock + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from update_checker import UpdateChecker + + +def test_parse_version_valid(): + """Test parse_version with valid version strings""" + checker = UpdateChecker("1.0.0") + + result = checker.parse_version("1.1.8") + assert result == (1, 1, 8), f"Expected (1, 1, 8), got {result}" + print(f" ✓ parse_version('1.1.8') = {result}") + + result = checker.parse_version("2.0") + assert result == (2, 0), f"Expected (2, 0), got {result}" + print(f" ✓ parse_version('2.0') = {result}") + + result = checker.parse_version("10.20.30") + assert result == (10, 20, 30), f"Expected (10, 20, 30), got {result}" + print(f" ✓ parse_version('10.20.30') = {result}") + + +def test_parse_version_invalid(): + """Test parse_version with invalid version strings""" + checker = UpdateChecker("1.0.0") + + # These produce (0,0,0) because regex finds no match + invalid_versions = ["", "abc", "not-a-version"] + for v in invalid_versions: + result = checker.parse_version(v) + assert result == (0, 0, 0), f"Expected (0, 0, 0) for '{v}', got {result}" + + # "v1.0" matches regex and returns (1, 0) - this is acceptable behavior + result = checker.parse_version("v1.0") + assert result == (1, 0) or result == (1, 0, 0), f"Expected (1, 0) for 'v1.0', got {result}" + + # "1.0.0.0" - regex only captures first 3 parts + result = checker.parse_version("1.0.0.0") + assert result == (1, 0, 0), f"Expected (1, 0, 0) for '1.0.0.0', got {result}" + + print(" ✓ parse_version handles invalid versions correctly") + + +def test_parse_version_none(): + """Test parse_version with None input""" + checker = UpdateChecker("1.0.0") + try: + result = checker.parse_version(None) + # If it doesn't raise, check result + assert result == (0, 0, 0), f"Expected (0, 0, 0) for None, got {result}" + except (AttributeError, TypeError): + # This is acceptable - function may not handle None gracefully + pass + print(" ✓ parse_version handles None (may raise or return (0,0,0))") + + +def test_check_for_update_newer_available(): + """Test check_for_update detects newer version""" + mock_response = MagicMock() + mock_response.read.return_value = json.dumps({ + 'tag_name': '2.0.0', + 'html_url': 'https://github.com/wardbryan3/SeleniumDownloader/releases/tag/2.0.0', + 'body': 'Major update with new features' + }).encode('utf-8') + + mock_response.__enter__ = lambda s: s + mock_response.__exit__ = lambda s, *a: False + + with patch('update_checker.urllib.request.urlopen') as mock_urlopen: + mock_urlopen.return_value = mock_response + + checker = UpdateChecker("1.1.8") + update_available, version, url, notes = checker.check_for_update() + + assert update_available is True, f"Expected update available, got {update_available}" + assert version == "2.0.0", f"Expected '2.0.0', got '{version}'" + assert "github.com" in url, f"Expected github URL, got '{url}'" + print(f" ✓ Detected newer version: {version}") + + +def test_check_for_update_current_is_latest(): + """Test check_for_update when current is latest""" + mock_response = MagicMock() + mock_response.read.return_value = json.dumps({ + 'tag_name': '1.1.8', + 'html_url': 'https://github.com/wardbryan3/SeleniumDownloader/releases/tag/1.1.8', + 'body': '' + }).encode('utf-8') + + mock_response.__enter__ = lambda s: s + mock_response.__exit__ = lambda s, *a: False + + with patch('update_checker.urllib.request.urlopen') as mock_urlopen: + mock_urlopen.return_value = mock_response + + checker = UpdateChecker("1.1.8") + update_available, version, url, notes = checker.check_for_update() + + assert update_available is False, "Should report no update available" + assert version == "1.1.8", f"Expected '1.1.8', got '{version}'" + print(f" ✓ Correctly reports no update (current is latest)") + + +def test_check_for_update_network_error(): + """Test check_for_update handles network errors""" + with patch('update_checker.urllib.request.urlopen') as mock_urlopen: + mock_urlopen.side_effect = Exception("Network error") + + checker = UpdateChecker("1.1.8") + update_available, version, url, notes = checker.check_for_update() + + assert update_available is False, "Should return False on error" + assert version == "", f"Expected empty version, got '{version}'" + print(" ✓ Handles network errors gracefully") + + +def test_check_for_update_invalid_json(): + """Test check_for_update handles invalid JSON response""" + mock_response = MagicMock() + mock_response.read.return_value = b"not valid json" + + mock_response.__enter__ = lambda s: s + mock_response.__exit__ = lambda s, *a: False + + with patch('update_checker.urllib.request.urlopen') as mock_urlopen: + mock_urlopen.return_value = mock_response + + checker = UpdateChecker("1.1.8") + update_available, version, url, notes = checker.check_for_update() + + assert update_available is False, "Should return False on invalid JSON" + print(" ✓ Handles invalid JSON gracefully") + + +def test_latest_version_property(): + """Test latest_version property""" + checker = UpdateChecker("1.0.0") + assert checker.latest_version == "unknown", "Should be 'unknown' before check" + print(" ✓ latest_version is 'unknown' before check") + + +def test_download_url_property(): + """Test download_url property""" + checker = UpdateChecker("1.0.0") + url = checker.download_url + assert "github.com" in url, f"Expected github URL, got '{url}'" + print(f" ✓ download_url: {url}") + + +def test_check_for_updates_async(): + """Test async update checker calls callback with result""" + mock_response = MagicMock() + mock_response.read.return_value = json.dumps({ + 'tag_name': '2.0.0', + 'html_url': 'https://github.com/wardbryan3/SeleniumDownloader/releases/tag/2.0.0', + 'body': '' + }).encode('utf-8') + + mock_response.__enter__ = lambda s: s + mock_response.__exit__ = lambda s, *a: False + + callback_results = [] + + def test_callback(result): + callback_results.append(result) + + with patch('update_checker.urllib.request.urlopen') as mock_urlopen: + mock_urlopen.return_value = mock_response + + from update_checker import check_for_updates_async + import time + check_for_updates_async("1.1.8", test_callback) + + time.sleep(1) + + assert len(callback_results) == 1, "Callback should be called once" + result = callback_results[0] + assert result[0] is True, "Should detect update" + assert result[1] == "2.0.0", f"Expected '2.0.0', got '{result[1]}'" + print(" ✓ Async checker calls callback with correct result") + + +def run_tests(): + """Run all update_checker tests""" + print("=" * 60) + print("Running Update Checker Tests") + print("=" * 60) + + tests = [ + test_parse_version_valid, + test_parse_version_invalid, + test_parse_version_none, + test_check_for_update_newer_available, + test_check_for_update_current_is_latest, + test_check_for_update_network_error, + test_check_for_update_invalid_json, + test_latest_version_property, + test_download_url_property, + test_check_for_updates_async, + ] + + passed = 0 + failed = 0 + + for test in tests: + try: + test() + passed += 1 + except AssertionError as e: + print(f" ✗ {test.__name__}: {e}") + failed += 1 + except Exception as e: + print(f" ✗ {test.__name__}: {e}") + failed += 1 + + print("=" * 60) + print(f"Results: {passed} passed, {failed} failed") + print("=" * 60) + + return 0 if failed == 0 else 1 + + +if __name__ == "__main__": + sys.exit(run_tests()) From 1d7d89b74c5a52bbc9a895e60b729c1ff61046be Mon Sep 17 00:00:00 2001 From: Bryan Ward Date: Fri, 1 May 2026 16:06:57 -0700 Subject: [PATCH 3/6] test: add run_all_tests.py and run tests in CI before build --- .github/workflows/windows_build.yml | 8 ++- run_all_tests.py | 80 +++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 run_all_tests.py diff --git a/.github/workflows/windows_build.yml b/.github/workflows/windows_build.yml index 0c77772..eee63dd 100644 --- a/.github/workflows/windows_build.yml +++ b/.github/workflows/windows_build.yml @@ -24,12 +24,18 @@ jobs: with: python-version: '3.11' + - name: Install Firefox + uses: browser-actions/setup-firefox@latest + - name: Install dependencies run: | python -m pip install --upgrade pip - pip install pyinstaller + pip install selenium webdriver-manager psutil watchdog pyinstaller if (Test-Path requirements.txt) { pip install -r requirements.txt } + - name: Run tests + run: python run_all_tests.py + - name: Build with PyInstaller using .spec file run: | pyinstaller --clean AudioDownloader.spec diff --git a/run_all_tests.py b/run_all_tests.py new file mode 100644 index 0000000..105b092 --- /dev/null +++ b/run_all_tests.py @@ -0,0 +1,80 @@ +""" +Run all test files in the project. +Usage: .venv/bin/python3 run_all_tests.py +""" + +import sys +import subprocess +from pathlib import Path + +# All test files to run +TEST_FILES = [ + "test_downloads.py", + "test_detection_standalone.py", + "tests/test_config_edge_cases.py", + "tests/test_integration.py", + "tests/test_scheduler.py", + "tests/test_scheduler_unit.py", + "tests/test_browser_manager.py", + "tests/test_browser_manager_unit.py", + "tests/test_sources.py", + "tests/test_sources_unit.py", + "tests/test_download_utils.py", + "tests/test_update_checker.py", + "tests/test_config_unit.py", +] + +def main(): + project_root = Path(__file__).parent + failed = 0 + results = [] + + print("=" * 60) + print("Running All Tests") + print("=" * 60) + print() + + for test_file in TEST_FILES: + test_path = project_root / test_file + if not test_path.exists(): + print(f" SKIP (not found): {test_file}") + continue + + print(f"Running: {test_file}...") + result = subprocess.run( + [sys.executable, str(test_path)], + capture_output=False, + cwd=str(project_root) + ) + + if result.returncode == 0: + print(f" ✓ PASSED: {test_file}") + results.append((test_file, "PASSED")) + else: + print(f" ✗ FAILED: {test_file} (exit code: {result.returncode})") + results.append((test_file, "FAILED")) + failed += 1 + print() + + print("=" * 60) + print("Summary:") + print("=" * 60) + for test_file, status in results: + marker = "✓" if status == "PASSED" else "✗" + print(f" {marker} {test_file}: {status}") + + print() + print(f"Total: {len(results)} tests, {len(results) - failed} passed, {failed} failed") + + if failed > 0: + print() + print("Some tests FAILED!") + sys.exit(1) + else: + print() + print("All tests PASSED!") + sys.exit(0) + + +if __name__ == "__main__": + main() From 704a322862f76682cd9eb0d44d0ec83708e55d75 Mon Sep 17 00:00:00 2001 From: Bryan Ward Date: Fri, 1 May 2026 16:17:09 -0700 Subject: [PATCH 4/6] docs: add README with quick start, prerequisites, and release workflow --- README.md | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..d704139 --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# Audio Downloader + +Python desktop app that uses Selenium (Firefox) to download radio show audio from 5 sources. + +## Quick Start + +**Windows users:** Download `AudioDownloader.exe` directly from [GitHub Releases](https://github.com/wardbryan3/SeleniumDownloader/releases) — no Python or dependencies needed. + +**From source:** +```bash +git clone ... +cd SeleniumDownloader +python3 -m venv .venv +.venv/bin/python3 -m pip install -r requirements.txt + +# Run GUI (default) +.venv/bin/python3 main.py + +# CLI: all sources +.venv/bin/python3 main.py --download-all + +# CLI: single source +.venv/bin/python3 main.py --source "Melinda Myers" +``` + +## Prerequisites + +- **Python 3.11+** +- **Firefox** (GeckoDriver auto-installed via webdriver-manager) +- **FFmpeg** (system package, required for audio tag overlay): + - Linux: `apt install ffmpeg` + - Windows: Download from https://ffmpeg.org/download.html + +## Configuration + +On first run, `download_config.json` is auto-created with defaults (test mode on). + +**Required fields to update:** +- `email` / `password` — Westwood One login +- `cow_password` — Clear Out West password +- `urls` — Replace `YOUR_LINK_HERE` placeholders with real URLs +- `test_mode` — Set to `False` for production (uses real Dropbox paths) + +## Testing + +```bash +.venv/bin/python3 run_all_tests.py +``` + +All tests are plain Python scripts (no framework). See `AGENTS.md` for developer details. + +## Building + +```bash +# Local build (Windows only) +pyinstaller --clean AudioDownloader.spec +# Output: dist/AudioDownloader.exe +``` + +CI automatically builds on push to `main` and creates a GitHub Release on tags. + +## Release + +1. Bump `__version__` in `__init__.py` (semver: `1.1.9`) +2. `git commit -m "chore: bump version to X.Y.Z"` +3. `git tag X.Y.Z && git push origin main --tags` + +The app checks `wardbryan3/SeleniumDownloader/releases/latest` and notifies users of updates. From aa889191532365924d2ed22a03fa7a966829d422 Mon Sep 17 00:00:00 2001 From: Bryan Ward Date: Fri, 1 May 2026 16:34:03 -0700 Subject: [PATCH 5/6] fix: replace Unicode check/X marks with ASCII for Windows CI compatibility --- run_all_tests.py | 4 +-- test_downloads.py | 8 ++--- tests/__init__.py | 16 +++++----- tests/test_browser_manager.py | 34 ++++++++++---------- tests/test_browser_manager_unit.py | 36 ++++++++++----------- tests/test_config_edge_cases.py | 22 ++++++------- tests/test_config_unit.py | 50 +++++++++++++++--------------- tests/test_download_utils.py | 30 +++++++++--------- tests/test_integration.py | 32 +++++++++---------- tests/test_scheduler.py | 24 +++++++------- tests/test_scheduler_unit.py | 30 +++++++++--------- tests/test_sources.py | 20 ++++++------ tests/test_sources_unit.py | 34 ++++++++++---------- tests/test_update_checker.py | 28 ++++++++--------- 14 files changed, 184 insertions(+), 184 deletions(-) diff --git a/run_all_tests.py b/run_all_tests.py index 105b092..d74111b 100644 --- a/run_all_tests.py +++ b/run_all_tests.py @@ -48,10 +48,10 @@ def main(): ) if result.returncode == 0: - print(f" ✓ PASSED: {test_file}") + print(f" [PASS] PASSED: {test_file}") results.append((test_file, "PASSED")) else: - print(f" ✗ FAILED: {test_file} (exit code: {result.returncode})") + print(f" [FAIL] FAILED: {test_file} (exit code: {result.returncode})") results.append((test_file, "FAILED")) failed += 1 print() diff --git a/test_downloads.py b/test_downloads.py index 53cdc62..f8d4044 100644 --- a/test_downloads.py +++ b/test_downloads.py @@ -144,9 +144,9 @@ def test_config_paths(): for folder in ['Global Features', 'WWO SPOTS', 'Promos']: path = config.get_global_features_dir().replace('Global Features', folder) if Path(path).exists(): - print(f"✓ {folder} folder exists") + print(f"[PASS] {folder} folder exists") else: - print(f"✗ {folder} folder missing") + print(f"[FAIL] {folder} folder missing") print("\n" + "=" * 50) @@ -188,9 +188,9 @@ def test_all_sources(): for name in DOWNLOAD_SOURCES.keys(): try: d = create_downloader(name, bm, config) - print(f"✓ {name}: OK") + print(f"[PASS] {name}: OK") except Exception as e: - print(f"✗ {name}: {e}") + print(f"[FAIL] {name}: {e}") print("\n" + "=" * 50) diff --git a/tests/__init__.py b/tests/__init__.py index ab7f426..b04f98d 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -20,7 +20,7 @@ def test_set_method(): cm = ConfigManager() cm.set("test_key", "test_value") assert cm.get("test_key") == "test_value", "set() failed" - print(" ✓ set() works") + print(" [PASS] set() works") def test_save_method(): """Test that save() method works""" @@ -31,7 +31,7 @@ def test_save_method(): result = cm.save() assert result is True, "save() returned False" assert cm.get("test_save_key") == test_value, "save() didn't persist value" - print(" ✓ save() works") + print(" [PASS] save() works") def test_url_config(): """Test that URLs load from config""" @@ -40,14 +40,14 @@ def test_url_config(): urls = cm.get("urls", {}) assert "northwest_outdoors" in urls, "northwest_outdoors URL key missing" assert "whittler" in urls, "whittler URL key missing" - print(f" ✓ northwest_outdoors URL: {urls.get('northwest_outdoors')}") - print(f" ✓ whittler URL: {urls.get('whittler')}") + print(f" [PASS] northwest_outdoors URL: {urls.get('northwest_outdoors')}") + print(f" [PASS] whittler URL: {urls.get('whittler')}") def test_cow_password_default(): """Test that cow_password defaults to empty string""" print("Testing cow_password default...") assert DEFAULT_CONFIG.get("cow_password") == "", f"cow_password should be empty" - print(" ✓ cow_password defaults to empty string") + print(" [PASS] cow_password defaults to empty string") def test_url_validation(): """Test URL validation in DEFAULT_CONFIG""" @@ -55,7 +55,7 @@ def test_url_validation(): urls = DEFAULT_CONFIG.get("urls", {}) assert "YOUR_LINK" in urls.get("northwest_outdoors", ""), "Placeholder URL should be in default" assert "YOUR_LINK" in urls.get("whittler", ""), "Placeholder URL should be in default" - print(" ✓ Placeholder URLs in DEFAULT_CONFIG detected correctly") + print(" [PASS] Placeholder URLs in DEFAULT_CONFIG detected correctly") def main(): print("=" * 50) @@ -74,10 +74,10 @@ def main(): print("=" * 50) return 0 except AssertionError as e: - print(f"\n✗ Test failed: {e}") + print(f"\n[FAIL] Test failed: {e}") return 1 except Exception as e: - print(f"\n✗ Error: {e}") + print(f"\n[FAIL] Error: {e}") import traceback traceback.print_exc() return 1 diff --git a/tests/test_browser_manager.py b/tests/test_browser_manager.py index 0974c61..d25373b 100644 --- a/tests/test_browser_manager.py +++ b/tests/test_browser_manager.py @@ -18,9 +18,9 @@ def test_browser_manager_imports(self): try: import browser_manager assert hasattr(browser_manager, 'BrowserManager'), "Should have BrowserManager class" - print(" ✓ browser_manager imports successfully") + print(" [PASS] browser_manager imports successfully") except ImportError as e: - print(f" ✗ Import failed: {e}") + print(f" [FAIL] Import failed: {e}") raise def test_browser_manager_has_required_methods(self): @@ -39,9 +39,9 @@ def test_browser_manager_has_required_methods(self): for method in required_methods: assert hasattr(BrowserManager, method), f"Missing method: {method}" - print(" ✓ BrowserManager has all required methods") + print(" [PASS] BrowserManager has all required methods") except ImportError as e: - print(f" ✗ Import failed: {e}") + print(f" [FAIL] Import failed: {e}") raise def test_selenium_webdriver_imports(self): @@ -53,9 +53,9 @@ def test_selenium_webdriver_imports(self): assert hasattr(webdriver, 'Firefox'), "Should have Firefox webdriver" - print(" ✓ Selenium Firefox imports successful") + print(" [PASS] Selenium Firefox imports successful") except ImportError as e: - print(f" ✗ Selenium import failed: {e}") + print(f" [FAIL] Selenium import failed: {e}") raise def test_webdriver_manager_imports(self): @@ -63,9 +63,9 @@ def test_webdriver_manager_imports(self): try: from webdriver_manager.firefox import GeckoDriverManager - print(" ✓ webdriver_manager GeckoDriverManager imports successful") + print(" [PASS] webdriver_manager GeckoDriverManager imports successful") except ImportError as e: - print(f" ✗ webdriver_manager import failed: {e}") + print(f" [FAIL] webdriver_manager import failed: {e}") raise @@ -85,7 +85,7 @@ def test_start_chrome_browser(self, mock_driver_manager, mock_chrome): bm = BrowserManager.__new__(BrowserManager) bm.browser_type = 'chrome' - print(" ✓ Chrome browser startup logic works") + print(" [PASS] Chrome browser startup logic works") except Exception as e: print(f" Note: {e} (expected without full browser setup)") @@ -102,7 +102,7 @@ def test_start_firefox_browser(self, mock_driver_manager, mock_firefox): bm = BrowserManager.__new__(BrowserManager) bm.browser_type = 'firefox' - print(" ✓ Firefox browser startup logic works") + print(" [PASS] Firefox browser startup logic works") except Exception as e: print(f" Note: {e} (expected without full browser setup)") @@ -119,9 +119,9 @@ def test_browser_options_configured(self): assert "--headless" in chrome_opts.arguments - print(" ✓ Browser options configuration works") + print(" [PASS] Browser options configuration works") except ImportError as e: - print(f" ✗ Import failed: {e}") + print(f" [FAIL] Import failed: {e}") raise @@ -138,7 +138,7 @@ def test_driver_quit_handles_errors(self): except Exception: pass - print(" ✓ Driver quit handles errors gracefully") + print(" [PASS] Driver quit handles errors gracefully") def test_close_browser_method_exists(self): """Test close_browser method exists""" @@ -147,9 +147,9 @@ def test_close_browser_method_exists(self): assert hasattr(BrowserManager, 'close_browser'), "Missing close_browser method" - print(" ✓ close_browser method exists") + print(" [PASS] close_browser method exists") except ImportError as e: - print(f" ✗ Import failed: {e}") + print(f" [FAIL] Import failed: {e}") raise @@ -181,10 +181,10 @@ def run_tests(): test() passed += 1 except AssertionError as e: - print(f" ✗ {test.__name__}: {e}") + print(f" [FAIL] {test.__name__}: {e}") failed += 1 except Exception as e: - print(f" ✗ {test.__name__}: {e}") + print(f" [FAIL] {test.__name__}: {e}") failed += 1 print("=" * 60) diff --git a/tests/test_browser_manager_unit.py b/tests/test_browser_manager_unit.py index cedbef8..dea28d6 100644 --- a/tests/test_browser_manager_unit.py +++ b/tests/test_browser_manager_unit.py @@ -37,7 +37,7 @@ def test_browser_manager_init(): bm = BrowserManager(cm) assert bm.config_manager is cm, "Config manager should be stored" assert bm.driver is None, "Driver should start as None" - print(" ✓ BrowserManager initializes correctly") + print(" [PASS] BrowserManager initializes correctly") def test_create_browser_options(): @@ -54,7 +54,7 @@ def test_create_browser_options(): prefs = options.preferences if hasattr(options, 'preferences') else options._preferences assert prefs.get("browser.download.folderList") == 2, "Should set download folderList to 2" assert "neverAsk.saveToDisk" in str(prefs), "Should set neverAsk.saveToDisk" - print(" ✓ Firefox options configured correctly") + print(" [PASS] Firefox options configured correctly") @patch('browser_manager.GeckoDriverManager') @@ -74,7 +74,7 @@ def test_start_browser_success(mock_firefox, mock_gecko): assert result is True, "start_browser should return True on success" assert bm.driver is mock_driver, "Driver should be set after start" mock_firefox.assert_called_once() - print(" ✓ start_browser succeeds and sets driver") + print(" [PASS] start_browser succeeds and sets driver") @patch('browser_manager.GeckoDriverManager') @@ -90,7 +90,7 @@ def test_start_browser_already_running(mock_firefox, mock_gecko): assert result is True, "Should return True when already running" mock_firefox.assert_not_called(), "Should not create new driver" - print(" ✓ start_browser returns True when already running") + print(" [PASS] start_browser returns True when already running") @patch('browser_manager.GeckoDriverManager') @@ -107,7 +107,7 @@ def test_start_browser_failure(mock_firefox, mock_gecko): assert result is False, "Should return False on failure" assert bm.driver is None, "Driver should be None on failure" - print(" ✓ start_browser returns False on failure") + print(" [PASS] start_browser returns False on failure") def test_close_browser(): @@ -122,7 +122,7 @@ def test_close_browser(): mock_driver.quit.assert_called_once() assert bm.driver is None, "Driver should be None after close" - print(" ✓ close_browser quits driver and clears it") + print(" [PASS] close_browser quits driver and clears it") def test_close_browser_when_none(): @@ -134,9 +134,9 @@ def test_close_browser_when_none(): try: bm.close_browser() - print(" ✓ close_browser handles None driver") + print(" [PASS] close_browser handles None driver") except Exception as e: - print(f" ✗ close_browser raised exception: {e}") + print(f" [FAIL] close_browser raised exception: {e}") def test_get_driver_auto_start(): @@ -149,7 +149,7 @@ def test_get_driver_auto_start(): with patch.object(bm, 'start_browser', return_value=True) as mock_start: result = bm.get_driver() mock_start.assert_called_once() - print(" ✓ get_driver auto-starts browser") + print(" [PASS] get_driver auto-starts browser") def test_get_driver_returns_existing(): @@ -164,7 +164,7 @@ def test_get_driver_returns_existing(): result = bm.get_driver() mock_start.assert_not_called() assert result is mock_driver, "Should return existing driver" - print(" ✓ get_driver returns existing driver") + print(" [PASS] get_driver returns existing driver") def test_is_browser_open(): @@ -180,7 +180,7 @@ def test_is_browser_open(): bm.driver = None assert bm.is_browser_open() is False, "Should be False after clearing driver" - print(" ✓ is_browser_open returns correct state") + print(" [PASS] is_browser_open returns correct state") @patch('browser_manager.webdriver.Firefox') @@ -207,7 +207,7 @@ def test_get_browser_downloads(mock_firefox): assert len(downloads) == 2, f"Expected 2 downloads, got {len(downloads)}" assert downloads[0]['name'] == 'test.mp3' - print(" ✓ get_browser_downloads returns parsed downloads") + print(" [PASS] get_browser_downloads returns parsed downloads") def test_get_browser_downloads_no_driver(): @@ -219,7 +219,7 @@ def test_get_browser_downloads_no_driver(): downloads = bm.get_browser_downloads() assert downloads == [], "Should return empty list when no driver" - print(" ✓ get_browser_downloads returns empty when no driver") + print(" [PASS] get_browser_downloads returns empty when no driver") @patch('browser_manager.webdriver.Firefox') @@ -233,7 +233,7 @@ def test_wait_for_browser_download_complete_timeout(mock_firefox): with patch.object(bm, 'get_browser_downloads', return_value=[]): result = bm.wait_for_browser_download_complete(timeout=1, poll_interval=0.1) assert result is None, "Should return None on timeout" - print(" ✓ wait_for_browser_download_complete handles timeout") + print(" [PASS] wait_for_browser_download_complete handles timeout") @patch('browser_manager.webdriver.Firefox') @@ -257,7 +257,7 @@ def test_wait_for_browser_download_complete_finds_file(mock_firefox): result = bm.wait_for_browser_download_complete(timeout=2, poll_interval=0.1) assert result is not None, "Should find the download" assert "test.mp3" in result, f"Should return file path, got {result}" - print(" ✓ wait_for_browser_download_complete finds file") + print(" [PASS] wait_for_browser_download_complete finds file") finally: if test_file.exists(): test_file.unlink() @@ -271,7 +271,7 @@ def test_initialize_download_directory(): download_dir = Path(bm._get_temp_download_dir()) assert download_dir.exists(), "Download directory should be created" - print(f" ✓ Download directory exists: {download_dir}") + print(f" [PASS] Download directory exists: {download_dir}") def run_tests(): @@ -306,10 +306,10 @@ def run_tests(): test() passed += 1 except AssertionError as e: - print(f" ✗ {test.__name__}: {e}") + print(f" [FAIL] {test.__name__}: {e}") failed += 1 except Exception as e: - print(f" ✗ {test.__name__}: {e}") + print(f" [FAIL] {test.__name__}: {e}") failed += 1 print("=" * 60) diff --git a/tests/test_config_edge_cases.py b/tests/test_config_edge_cases.py index 823ef44..a4b7098 100644 --- a/tests/test_config_edge_cases.py +++ b/tests/test_config_edge_cases.py @@ -36,7 +36,7 @@ def test_default_config_has_all_required_keys(self): for key in required_keys: assert key in DEFAULT_CONFIG, f"Missing required key: {key}" - print(" ✓ All required keys present in DEFAULT_CONFIG") + print(" [PASS] All required keys present in DEFAULT_CONFIG") def test_url_validation_rejects_invalid_urls(self): """Test URL validation logic from sources""" @@ -51,7 +51,7 @@ def test_url_validation_rejects_invalid_urls(self): for url in invalid_urls: is_invalid = not url or "YOUR_LINK" in str(url) or "REMOVED" in str(url) assert is_invalid, f"URL should be invalid: {url}" - print(" ✓ Invalid URLs correctly identified") + print(" [PASS] Invalid URLs correctly identified") def test_url_validation_accepts_valid_urls(self): """Test that valid Dropbox URLs pass validation""" @@ -63,7 +63,7 @@ def test_url_validation_accepts_valid_urls(self): for url in valid_urls: is_valid = url and "YOUR_LINK" not in url and "REMOVED" not in url assert is_valid, f"URL should be valid: {url}" - print(" ✓ Valid URLs correctly identified") + print(" [PASS] Valid URLs correctly identified") def test_config_merge_user_overrides_defaults(self): """Test that user config properly overrides defaults""" @@ -85,7 +85,7 @@ def test_config_merge_user_overrides_defaults(self): assert merged["cow_password"] == "secret" assert merged["urls"]["northwest_outdoors"] == "https://custom.url/1" assert merged["urls"]["whittler"] == "https://custom.url/2" - print(" ✓ User config properly overrides defaults") + print(" [PASS] User config properly overrides defaults") def test_test_mode_affects_paths(self): """Test that test_mode changes directory paths correctly""" @@ -96,14 +96,14 @@ def test_test_mode_affects_paths(self): test_dir = cm.get_test_downloads_dir() assert "test_downloads" in test_dir - print(f" ✓ Test mode path: {test_dir}") + print(f" [PASS] Test mode path: {test_dir}") cm.config["test_mode"] = False cm.config["dropbox_base"] = "D:/Dropbox" prod_dir = cm.get_output_base_dir() assert "Dropbox" in prod_dir - print(f" ✓ Production mode path: {prod_dir}") + print(f" [PASS] Production mode path: {prod_dir}") def test_retry_attempts_validation(self): """Test retry_attempts must be valid integer""" @@ -117,7 +117,7 @@ def test_retry_attempts_validation(self): is_invalid = not (isinstance(val, int) and val >= 0) assert is_invalid, f"Retry {val} should be invalid" - print(" ✓ Retry attempts validation works correctly") + print(" [PASS] Retry attempts validation works correctly") def test_scheduled_time_format_validation(self): """Test scheduled download time format validation""" @@ -141,7 +141,7 @@ def test_scheduled_time_format_validation(self): is_valid = False assert not is_valid, f"Time {time_str} should be invalid" - print(" ✓ Time format validation works correctly") + print(" [PASS] Time format validation works correctly") def test_config_validate_returns_errors_for_missing_required(self): """Test that validate_config returns errors for missing required fields""" @@ -157,7 +157,7 @@ def test_config_validate_returns_errors_for_missing_required(self): assert any("password" in e.lower() for e in errors), "Should report missing password" assert any("cow" in e.lower() for e in errors), "Should report missing cow_password" - print(f" ✓ Found {len(errors)} validation errors: {errors}") + print(f" [PASS] Found {len(errors)} validation errors: {errors}") def run_tests(): @@ -187,10 +187,10 @@ def run_tests(): test() passed += 1 except AssertionError as e: - print(f" ✗ {test.__name__}: {e}") + print(f" [FAIL] {test.__name__}: {e}") failed += 1 except Exception as e: - print(f" ✗ {test.__name__}: {e}") + print(f" [FAIL] {test.__name__}: {e}") failed += 1 print("=" * 60) diff --git a/tests/test_config_unit.py b/tests/test_config_unit.py index 3d82a91..7841aae 100644 --- a/tests/test_config_unit.py +++ b/tests/test_config_unit.py @@ -25,13 +25,13 @@ def test_default_config_has_required_keys(): for key in required_keys: assert key in DEFAULT_CONFIG, f"Missing required key: {key}" - print(" ✓ All required keys present in DEFAULT_CONFIG") + print(" [PASS] All required keys present in DEFAULT_CONFIG") def test_default_config_test_mode_true(): """Test DEFAULT_CONFIG has test_mode=True""" assert DEFAULT_CONFIG["test_mode"] is True, "test_mode should default to True" - print(" ✓ DEFAULT_CONFIG test_mode is True") + print(" [PASS] DEFAULT_CONFIG test_mode is True") def test_default_config_urls_placeholders(): @@ -40,7 +40,7 @@ def test_default_config_urls_placeholders(): for source, url in urls.items(): assert "YOUR_LINK" in url, f"{source} URL should be placeholder, got {url}" - print(f" ✓ All {len(urls)} source URLs are placeholders") + print(f" [PASS] All {len(urls)} source URLs are placeholders") def test_config_load_missing_file(): @@ -60,7 +60,7 @@ def test_config_load_missing_file(): with open(config_path) as f: saved = json.load(f) assert saved["test_mode"] is True, "Saved config should have test_mode=True" - print(" ✓ Creates default config when file missing") + print(" [PASS] Creates default config when file missing") finally: config_module.CONFIG_FILE = original @@ -87,7 +87,7 @@ def test_config_load_existing_file(): assert cm.get("email") == "test@test.com", "Should load email" # Non-specified keys should come from DEFAULT_CONFIG assert cm.get("auto_close_browser") is True, "Should use default for unset keys" - print(" ✓ Loads existing config and merges with defaults") + print(" [PASS] Loads existing config and merges with defaults") finally: config_module.CONFIG_FILE = original @@ -111,7 +111,7 @@ def test_config_save_and_reload(): cm2 = ConfigManager() assert cm2.get("email") == "save_test@test.com", "Should persist email" assert cm2.get("cow_password") == "saved_secret", "Should persist cow_password" - print(" ✓ Config persists across save/reload") + print(" [PASS] Config persists across save/reload") finally: config_module.CONFIG_FILE = original @@ -140,7 +140,7 @@ def test_config_merge_nested_dicts(): assert loaded_urls == test_urls, f"URLs should be fully replaced, got {loaded_urls}" assert "northwest_outdoors" in loaded_urls, "Should have northwest_outdoors" assert "whittler" in loaded_urls, "Should have whittler" - print(" ✓ Nested dicts (urls) fully replaced on merge") + print(" [PASS] Nested dicts (urls) fully replaced on merge") finally: config_module.CONFIG_FILE = original @@ -153,7 +153,7 @@ def test_get_output_base_dir_test_mode(): result = cm.get_output_base_dir() assert "test_output" in result, f"Should return test dir, got {result}" - print(f" ✓ Test mode returns test dir: {result}") + print(f" [PASS] Test mode returns test dir: {result}") def test_get_output_base_dir_prod_mode(): @@ -164,7 +164,7 @@ def test_get_output_base_dir_prod_mode(): result = cm.get_output_base_dir() assert "Dropbox" in result, f"Should return dropbox dir, got {result}" - print(f" ✓ Production mode returns dropbox dir: {result}") + print(f" [PASS] Production mode returns dropbox dir: {result}") def test_get_test_downloads_dir(): @@ -174,7 +174,7 @@ def test_get_test_downloads_dir(): result = cm.get_test_downloads_dir() assert "my_tests" in result, f"Should contain test dir name, got {result}" - print(f" ✓ get_test_downloads_dir: {result}") + print(f" [PASS] get_test_downloads_dir: {result}") def test_ensure_folders_test_mode(): @@ -189,7 +189,7 @@ def test_ensure_folders_test_mode(): assert Path(cm.get_test_downloads_dir()).exists(), "Test downloads dir should exist" assert Path(cm.get_global_features_dir()).exists(), "Global Features dir should exist" - print(" ✓ Test mode folders created") + print(" [PASS] Test mode folders created") def test_ensure_folders_prod_mode(): @@ -199,7 +199,7 @@ def test_ensure_folders_prod_mode(): # This may fail in test env without real Dropbox - that's OK result = cm.ensure_folders() - print(f" ✓ ensure_folders in prod mode returned: {result}") + print(f" [PASS] ensure_folders in prod mode returned: {result}") def test_validate_config_missing_email(): @@ -212,7 +212,7 @@ def test_validate_config_missing_email(): errors = cm.validate_config() email_errors = [e for e in errors if "email" in e.lower()] assert len(email_errors) > 0, "Should report missing email" - print(f" ✓ Detects missing email: {email_errors[0]}") + print(f" [PASS] Detects missing email: {email_errors[0]}") def test_validate_config_missing_password(): @@ -225,7 +225,7 @@ def test_validate_config_missing_password(): errors = cm.validate_config() password_errors = [e for e in errors if "password" in e.lower()] assert len(password_errors) > 0, "Should report missing password" - print(f" ✓ Detects missing password: {password_errors[0]}") + print(f" [PASS] Detects missing password: {password_errors[0]}") def test_validate_config_missing_cow_password(): @@ -238,7 +238,7 @@ def test_validate_config_missing_cow_password(): errors = cm.validate_config() cow_errors = [e for e in errors if "cow" in e.lower()] assert len(cow_errors) > 0, "Should report missing cow_password" - print(f" ✓ Detects missing cow_password: {cow_errors[0]}") + print(f" [PASS] Detects missing cow_password: {cow_errors[0]}") def test_validate_config_valid(): @@ -250,7 +250,7 @@ def test_validate_config_valid(): errors = cm.validate_config() assert len(errors) == 0, f"Should have no errors, got {errors}" - print(" ✓ validate_config passes with all required fields") + print(" [PASS] validate_config passes with all required fields") def test_validate_config_invalid_time(): @@ -261,7 +261,7 @@ def test_validate_config_invalid_time(): errors = cm.validate_config() time_errors = [e for e in errors if "time" in e.lower()] assert len(time_errors) > 0, "Should report invalid time" - print(f" ✓ Detects invalid time format: {time_errors[0]}") + print(f" [PASS] Detects invalid time format: {time_errors[0]}") def test_validate_retry_attempts_valid(): @@ -273,7 +273,7 @@ def test_validate_retry_attempts_valid(): errors = cm.validate_config() retry_errors = [e for e in errors if "retry" in e.lower()] assert len(retry_errors) == 0, f"Retry {val} should be valid" - print(" ✓ Valid retry_attempts accepted") + print(" [PASS] Valid retry_attempts accepted") def test_validate_retry_attempts_invalid(): @@ -285,7 +285,7 @@ def test_validate_retry_attempts_invalid(): errors = cm.validate_config() retry_errors = [e for e in errors if "retry" in e.lower()] assert len(retry_errors) > 0, f"Retry {val} should be invalid" - print(" ✓ Invalid retry_attempts rejected") + print(" [PASS] Invalid retry_attempts rejected") def test_day_mapping_complete(): @@ -300,7 +300,7 @@ def test_day_mapping_complete(): "Sunday": "Sun" } assert DAY_MAPPING == expected, f"DAY_MAPPING mismatch: {DAY_MAPPING}" - print(f" ✓ DAY_MAPPING complete: {list(DAY_MAPPING.keys())}") + print(f" [PASS] DAY_MAPPING complete: {list(DAY_MAPPING.keys())}") def test_get_browser_download_dir(): @@ -309,7 +309,7 @@ def test_get_browser_download_dir(): result = cm.get_browser_download_dir() assert result is not None, "Should return a path" assert len(result) > 0, "Path should not be empty" - print(f" ✓ Browser download dir: {result}") + print(f" [PASS] Browser download dir: {result}") def test_clear_browser_download_dir(): @@ -327,7 +327,7 @@ def test_clear_browser_download_dir(): cm.clear_browser_download_dir() assert not test_file.exists(), "Test file should be deleted" - print(" ✓ clear_browser_download_dir removes files") + print(" [PASS] clear_browser_download_dir removes files") def test_get_scheduled_config(): @@ -339,7 +339,7 @@ def test_get_scheduled_config(): assert "enabled" in scheduled, "Should have enabled key" assert "schedule_type" in scheduled, "Should have schedule_type key" assert "time" in scheduled, "Should have time key" - print(f" ✓ get_scheduled_config returns: {list(scheduled.keys())}") + print(f" [PASS] get_scheduled_config returns: {list(scheduled.keys())}") def run_tests(): @@ -382,10 +382,10 @@ def run_tests(): test() passed += 1 except AssertionError as e: - print(f" ✗ {test.__name__}: {e}") + print(f" [FAIL] {test.__name__}: {e}") failed += 1 except Exception as e: - print(f" ✗ {test.__name__}: {e}") + print(f" [FAIL] {test.__name__}: {e}") failed += 1 print("=" * 60) diff --git a/tests/test_download_utils.py b/tests/test_download_utils.py index 25acce7..e474a4c 100644 --- a/tests/test_download_utils.py +++ b/tests/test_download_utils.py @@ -24,7 +24,7 @@ def test_get_file_hash_valid_file(): hash_result = DownloadUtilities.get_file_hash(filepath) assert hash_result != "", "Hash should not be empty" assert len(hash_result) == 32, f"MD5 hash should be 32 chars, got {len(hash_result)}" - print(f" ✓ get_file_hash returns valid MD5: {hash_result[:8]}...") + print(f" [PASS] get_file_hash returns valid MD5: {hash_result[:8]}...") finally: os.unlink(filepath) @@ -33,7 +33,7 @@ def test_get_file_hash_missing_file(): """Test get_file_hash returns empty string for missing file""" result = DownloadUtilities.get_file_hash("/nonexistent/path/file.txt") assert result == "", "Should return empty string for missing file" - print(" ✓ get_file_hash handles missing file gracefully") + print(" [PASS] get_file_hash handles missing file gracefully") def test_get_file_hash_empty_file(): @@ -44,7 +44,7 @@ def test_get_file_hash_empty_file(): try: hash_result = DownloadUtilities.get_file_hash(filepath) assert hash_result != "", "Empty file should still produce a hash" - print(" ✓ get_file_hash handles empty file") + print(" [PASS] get_file_hash handles empty file") finally: os.unlink(filepath) @@ -59,7 +59,7 @@ def test_is_file_locked_unlocked_file(): try: locked = DownloadUtilities.is_file_locked(filepath) assert locked is False, "Unopened file should not be locked" - print(" ✓ is_file_locked returns False for closed file") + print(" [PASS] is_file_locked returns False for closed file") finally: os.unlink(filepath) @@ -74,7 +74,7 @@ def test_is_file_locked_opened_file(): try: locked = DownloadUtilities.is_file_locked(filepath) assert locked is True, "Opened file should be detected as locked" - print(" ✓ is_file_locked detects open file handle") + print(" [PASS] is_file_locked detects open file handle") except AssertionError: print(" Note: is_file_locked may not detect locks on all platforms") finally: @@ -88,7 +88,7 @@ def test_allowed_extensions(): assert '.wav' in ALLOWED_EXTENSIONS, "Should allow .wav" assert '.zip' in ALLOWED_EXTENSIONS, "Should allow .zip" assert '.pdf' in ALLOWED_EXTENSIONS, "Should allow .pdf" - print(f" ✓ ALLOWED_EXTENSIONS: {sorted(ALLOWED_EXTENSIONS)}") + print(f" [PASS] ALLOWED_EXTENSIONS: {sorted(ALLOWED_EXTENSIONS)}") def test_excluded_extensions(): @@ -97,14 +97,14 @@ def test_excluded_extensions(): assert '.crdownload' in EXCLUDED_EXTENSIONS, "Should exclude .crdownload" assert '.tmp' in EXCLUDED_EXTENSIONS, "Should exclude .tmp" assert '.download' in EXCLUDED_EXTENSIONS, "Should exclude .download" - print(f" ✓ EXCLUDED_EXTENSIONS: {sorted(EXCLUDED_EXTENSIONS)}") + print(f" [PASS] EXCLUDED_EXTENSIONS: {sorted(EXCLUDED_EXTENSIONS)}") def test_excluded_prefixes(): """Test EXCLUDED_PREFIXES contains expected values""" assert '.fea' in EXCLUDED_PREFIXES, "Should exclude .fea prefix" assert '.X' in EXCLUDED_PREFIXES, "Should exclude .X prefix" - print(f" ✓ EXCLUDED_PREFIXES: {sorted(EXCLUDED_PREFIXES)}") + print(f" [PASS] EXCLUDED_PREFIXES: {sorted(EXCLUDED_PREFIXES)}") def test_find_latest_file_empty_dir(): @@ -112,7 +112,7 @@ def test_find_latest_file_empty_dir(): with tempfile.TemporaryDirectory() as tmpdir: result = DownloadUtilities.find_latest_file(tmpdir) assert result is None, "Should return None for empty directory" - print(" ✓ find_latest_file returns None for empty dir") + print(" [PASS] find_latest_file returns None for empty dir") def test_find_latest_file_with_files(): @@ -126,7 +126,7 @@ def test_find_latest_file_with_files(): result = DownloadUtilities.find_latest_file(tmpdir, extension='.mp3') assert result is not None, "Should find a file" assert "new.mp3" in result, f"Should return newest file, got {result}" - print(f" ✓ find_latest_file returns newest: {Path(result).name}") + print(f" [PASS] find_latest_file returns newest: {Path(result).name}") def test_find_latest_file_ignores_temp_files(): @@ -142,7 +142,7 @@ def test_find_latest_file_ignores_temp_files(): result = DownloadUtilities.find_latest_file(tmpdir, extension='.mp3') assert result is not None, "Should find a file" assert "temp" not in result, "Should not return .part file" - print(" ✓ find_latest_file ignores temp files") + print(" [PASS] find_latest_file ignores temp files") def test_simple_wait_for_download_timeout(): @@ -152,7 +152,7 @@ def test_simple_wait_for_download_timeout(): tmpdir, expected_extensions=['.mp3'], timeout=1 ) assert result is None, "Should timeout and return None" - print(" ✓ simple_wait_for_download handles timeout gracefully") + print(" [PASS] simple_wait_for_download handles timeout gracefully") def test_simple_wait_for_download_detects_file(): @@ -174,7 +174,7 @@ def add_file_delayed(): t.join() assert result is not None, "Should detect new file" assert "test.mp3" in result, f"Should return the new file, got {result}" - print(f" ✓ simple_wait_for_download detected file: {Path(result).name}") + print(f" [PASS] simple_wait_for_download detected file: {Path(result).name}") def run_tests(): @@ -207,10 +207,10 @@ def run_tests(): test() passed += 1 except AssertionError as e: - print(f" ✗ {test.__name__}: {e}") + print(f" [FAIL] {test.__name__}: {e}") failed += 1 except Exception as e: - print(f" ✗ {test.__name__}: {e}") + print(f" [FAIL] {test.__name__}: {e}") failed += 1 print("=" * 60) diff --git a/tests/test_integration.py b/tests/test_integration.py index 48870c4..0f3a00b 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -31,7 +31,7 @@ def test_config_manager_workflow(self): cm2.set("test_key", None) cm2.save() - print(" ✓ Full ConfigManager workflow works") + print(" [PASS] Full ConfigManager workflow works") def test_source_initialization(self): """Test that all sources can be imported""" @@ -43,7 +43,7 @@ def test_source_initialization(self): WestwoodOneDownloader, ClearOutWestDownloader, ) - print(" ✓ All source downloaders can be imported") + print(" [PASS] All source downloaders can be imported") except ImportError as e: print(f" Note: Import failed - {e}") @@ -63,7 +63,7 @@ def test_downloader_has_required_methods(self): for method in required_methods: assert hasattr(BaseDownloader, method), f"Missing method: {method}" - print(" ✓ BaseDownloader has all required methods") + print(" [PASS] BaseDownloader has all required methods") def test_promo_tag_workflow(self): """Test promo tag overlay workflow""" @@ -73,11 +73,11 @@ def test_promo_tag_workflow(self): tag_file = cm.get_tag_file() assert tag_file is not None - print(f" ✓ Tag file path: {tag_file}") + print(f" [PASS] Tag file path: {tag_file}") promos_dir = cm.get_promos_dir() assert promos_dir is not None - print(f" ✓ Promos directory: {promos_dir}") + print(f" [PASS] Promos directory: {promos_dir}") class TestEndToEndScenarios: @@ -101,7 +101,7 @@ def test_northwest_outdoors_workflow(self, mock_start_browser): is_valid = bool(url) and "YOUR_LINK" not in url assert is_valid, "URL should be valid" - print(f" ✓ Northwest Outdoors workflow ready with valid URL") + print(f" [PASS] Northwest Outdoors workflow ready with valid URL") @patch('sources.base.BaseDownloader.start_browser') def test_whittler_workflow(self, mock_start_browser): @@ -121,7 +121,7 @@ def test_whittler_workflow(self, mock_start_browser): is_valid = bool(url) and "YOUR_LINK" not in url assert is_valid, "URL should be valid" - print(f" ✓ Whittler workflow ready with valid URL") + print(f" [PASS] Whittler workflow ready with valid URL") def test_test_mode_path_workflow(self): """Test test mode vs production mode paths""" @@ -144,8 +144,8 @@ def test_test_mode_path_workflow(self): prod_output = cm.get_output_base_dir() assert "Dropbox" in prod_output - print(f" ✓ Test mode: {test_output}") - print(f" ✓ Production mode: {prod_output}") + print(f" [PASS] Test mode: {test_output}") + print(f" [PASS] Production mode: {prod_output}") def test_validate_config_workflow(self): """Test config validation workflow""" @@ -167,7 +167,7 @@ def test_validate_config_workflow(self): assert not password_error, "Should not have password error" assert not cow_error, "Should not have cow_password error" - print(" ✓ Config validation passes with all required fields") + print(" [PASS] Config validation passes with all required fields") def test_browser_download_dir_workflow(self): """Test browser download directory workflow""" @@ -180,10 +180,10 @@ def test_browser_download_dir_workflow(self): assert download_dir is not None assert len(download_dir) > 0 - print(f" ✓ Browser download dir: {download_dir}") + print(f" [PASS] Browser download dir: {download_dir}") cm.clear_browser_download_dir() - print(" ✓ Browser download dir cleared") + print(" [PASS] Browser download dir cleared") class TestErrorHandling: @@ -207,7 +207,7 @@ def test_missing_config_file_creates_default(self): cm = ConfigManager() assert os.path.exists(temp_config.name), "Config file should be created" - print(f" ✓ Missing config creates default at: {temp_config.name}") + print(f" [PASS] Missing config creates default at: {temp_config.name}") finally: config.CONFIG_FILE = original_config_file if os.path.exists(temp_config.name): @@ -223,7 +223,7 @@ def test_invalid_json_handled(self): try: cm = ConfigManager() - print(" ✓ Invalid JSON handled gracefully") + print(" [PASS] Invalid JSON handled gracefully") except Exception as e: print(f" Note: {e}") finally: @@ -256,10 +256,10 @@ def run_tests(): test() passed += 1 except AssertionError as e: - print(f" ✗ {test.__name__}: {e}") + print(f" [FAIL] {test.__name__}: {e}") failed += 1 except Exception as e: - print(f" ✗ {test.__name__}: {e}") + print(f" [FAIL] {test.__name__}: {e}") failed += 1 print("=" * 60) diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index cf10675..6855890 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -29,7 +29,7 @@ def test_day_mapping(self): for full, short in expected_mappings.items(): actual = DAY_MAPPING.get(full) assert actual == short, f"{full}: expected {short}, got {actual}" - print(f" ✓ {full} -> {actual}") + print(f" [PASS] {full} -> {actual}") def test_time_format_validation(self): """Test time format validation using datetime.strptime""" @@ -39,7 +39,7 @@ def test_time_format_validation(self): for time_str in valid_times: try: datetime.strptime(time_str, '%H:%M') - print(f" ✓ {time_str} is valid") + print(f" [PASS] {time_str} is valid") except ValueError: assert False, f"{time_str} should be valid" @@ -54,7 +54,7 @@ def test_time_format_validation(self): except ValueError: is_invalid = True assert is_invalid, f"{time_str} should be invalid" - print(f" ✓ {time_str} correctly rejected") + print(f" [PASS] {time_str} correctly rejected") def test_schedule_types(self): """Test different schedule types used in scheduler""" @@ -62,7 +62,7 @@ def test_schedule_types(self): for schedule_type in valid_schedule_types: assert schedule_type in valid_schedule_types - print(f" ✓ Schedule type '{schedule_type}' is valid") + print(f" [PASS] Schedule type '{schedule_type}' is valid") def test_day_list_validation(self): """Test day list validation""" @@ -99,7 +99,7 @@ def test_next_run_calculation(self): else: expected = now.replace(hour=target_time.hour, minute=target_time.minute) + timedelta(days=1) - print(f" ✓ Next run for {target_time}: {expected.strftime('%H:%M')}") + print(f" [PASS] Next run for {target_time}: {expected.strftime('%H:%M')}") def test_schedule_enabled_check(self): """Test schedule enabled/disabled logic""" @@ -114,7 +114,7 @@ def test_schedule_enabled_check(self): is_enabled = schedule.get("enabled", False) assert is_enabled, "Schedule should be enabled" - print(" ✓ Schedule enabled check works") + print(" [PASS] Schedule enabled check works") def test_selected_sources_handling(self): """Test selected sources vs download_all flag""" @@ -131,7 +131,7 @@ def test_selected_sources_handling(self): result = schedule.get("selected_sources", []) assert result == expected, f"Expected {expected}, got {result}" - print(f" ✓ download_all={schedule.get('download_all')} -> {result}") + print(f" [PASS] download_all={schedule.get('download_all')} -> {result}") class TestSchedulerIntegration: @@ -147,7 +147,7 @@ def test_scheduled_config_structure(self): for key in required_keys: assert key in scheduled, f"Missing key: {key}" - print(f" ✓ Scheduled config has all required keys: {list(scheduled.keys())}") + print(f" [PASS] Scheduled config has all required keys: {list(scheduled.keys())}") def test_scheduled_config_defaults(self): """Test scheduled_downloads default values""" @@ -162,7 +162,7 @@ def test_scheduled_config_defaults(self): assert scheduled.get("download_all") == True assert scheduled.get("selected_sources") == [] - print(" ✓ Scheduled config defaults are correct") + print(" [PASS] Scheduled config defaults are correct") def test_get_scheduled_config_method(self): """Test ConfigManager.get_scheduled_config method""" @@ -174,7 +174,7 @@ def test_get_scheduled_config_method(self): assert isinstance(scheduled, dict), "Should return dict" assert "enabled" in scheduled, "Should have enabled key" - print(f" ✓ get_scheduled_config returns: {list(scheduled.keys())}") + print(f" [PASS] get_scheduled_config returns: {list(scheduled.keys())}") except Exception as e: print(f" Note: {e}") @@ -205,10 +205,10 @@ def run_tests(): test() passed += 1 except AssertionError as e: - print(f" ✗ {test.__name__}: {e}") + print(f" [FAIL] {test.__name__}: {e}") failed += 1 except Exception as e: - print(f" ✗ {test.__name__}: {e}") + print(f" [FAIL] {test.__name__}: {e}") failed += 1 print("=" * 60) diff --git a/tests/test_scheduler_unit.py b/tests/test_scheduler_unit.py index d4bee20..bc77bdc 100644 --- a/tests/test_scheduler_unit.py +++ b/tests/test_scheduler_unit.py @@ -26,7 +26,7 @@ def test_scheduler_init(): assert scheduler.running is False, "Should not be running initially" assert scheduler.jobs == [], "Jobs should start empty" assert scheduler.thread is None, "Thread should be None initially" - print(" ✓ DownloadScheduler initializes correctly") + print(" [PASS] DownloadScheduler initializes correctly") def test_scheduler_start_stop(): @@ -43,7 +43,7 @@ def test_scheduler_start_stop(): scheduler.stop() time.sleep(0.1) # Give thread time to stop assert scheduler.running is False, "Should not be running after stop" - print(" ✓ start()/stop() lifecycle works") + print(" [PASS] start()/stop() lifecycle works") def test_scheduler_start_already_running(): @@ -58,7 +58,7 @@ def test_scheduler_start_already_running(): scheduler.start() # Call start again assert scheduler.thread is original_thread, "Should not create new thread" scheduler.stop() - print(" ✓ start() is no-op when already running") + print(" [PASS] start() is no-op when already running") def test_update_from_config_disabled(): @@ -72,7 +72,7 @@ def test_update_from_config_disabled(): scheduler.update_from_config() assert len(scheduler.jobs) == 0, "No jobs should be added when disabled" - print(" ✓ No jobs added when scheduler disabled") + print(" [PASS] No jobs added when scheduler disabled") def test_update_from_config_daily(): @@ -96,7 +96,7 @@ def test_update_from_config_daily(): assert job['type'] == "daily", f"Job type should be daily, got {job['type']}" assert job['time'] == "06:00", f"Job time should be 06:00, got {job['time']}" assert job['last_run'] is None, "last_run should be None initially" - print(" ✓ Daily job created from config") + print(" [PASS] Daily job created from config") def test_update_from_config_weekly(): @@ -120,7 +120,7 @@ def test_update_from_config_weekly(): assert job['type'] == "weekly", "Job type should be weekly" assert "Mon" in job['days'], f"Should have Mon in days, got {job['days']}" assert "Fri" in job['days'], f"Should have Fri in days, got {job['days']}" - print(f" ✓ Weekly job created with days: {job['days']}") + print(f" [PASS] Weekly job created with days: {job['days']}") def test_update_from_config_invalid_time(): @@ -142,7 +142,7 @@ def test_update_from_config_invalid_time(): # Should fall back to 06:00 assert len(scheduler.jobs) == 1, "Should still create job" assert scheduler.jobs[0]['time'] == "06:00", "Should fall back to 06:00" - print(" ✓ Invalid time falls back to 06:00") + print(" [PASS] Invalid time falls back to 06:00") def test_run_pending_disabled(): @@ -157,7 +157,7 @@ def test_run_pending_disabled(): scheduler.run_pending() callback.assert_not_called(), "Callback should not be called when disabled" - print(" ✓ run_pending does nothing when disabled") + print(" [PASS] run_pending does nothing when disabled") def test_run_pending_time_match_daily(): @@ -183,7 +183,7 @@ def test_run_pending_time_match_daily(): # Should run because time matches scheduler.run_pending() callback.assert_called_once() - print(" ✓ Daily job fires when time matches") + print(" [PASS] Daily job fires when time matches") def test_run_pending_time_mismatch(): @@ -205,7 +205,7 @@ def test_run_pending_time_mismatch(): scheduler.run_pending() callback.assert_not_called(), "Callback should not be called when time doesn't match" - print(" ✓ Job doesn't fire when time doesn't match") + print(" [PASS] Job doesn't fire when time doesn't match") def test_run_pending_weekly_day_match(): @@ -230,7 +230,7 @@ def test_run_pending_weekly_day_match(): scheduler.run_pending() callback.assert_called_once() - print(f" ✓ Weekly job fires on correct day ({current_day})") + print(f" [PASS] Weekly job fires on correct day ({current_day})") def test_run_pending_weekly_day_mismatch(): @@ -259,7 +259,7 @@ def test_run_pending_weekly_day_mismatch(): scheduler.run_pending() callback.assert_not_called(), "Callback should not fire on wrong day" - print(f" ✓ Weekly job doesn't fire on wrong day (today: {today}, scheduled: {wrong_day})") + print(f" [PASS] Weekly job doesn't fire on wrong day (today: {today}, scheduled: {wrong_day})") def test_run_pending_no_double_run(): @@ -289,7 +289,7 @@ def test_run_pending_no_double_run(): callback.reset_mock() scheduler.run_pending() callback.assert_not_called(), "Should not fire again in same minute" - print(" ✓ Job doesn't fire twice in same minute") + print(" [PASS] Job doesn't fire twice in same minute") def run_tests(): @@ -323,10 +323,10 @@ def run_tests(): test() passed += 1 except AssertionError as e: - print(f" ✗ {test.__name__}: {e}") + print(f" [FAIL] {test.__name__}: {e}") failed += 1 except Exception as e: - print(f" ✗ {test.__name__}: {e}") + print(f" [FAIL] {test.__name__}: {e}") failed += 1 print("=" * 60) diff --git a/tests/test_sources.py b/tests/test_sources.py index 6c9312b..9d9bbc6 100644 --- a/tests/test_sources.py +++ b/tests/test_sources.py @@ -21,7 +21,7 @@ def test_northwest_outdoors_url_validation(): is_valid = bool(url) and "YOUR_LINK" in url and "REMOVED" not in url assert is_valid, f"URL should be valid: {url}" - print(f" ✓ northwest_outdoors URL: {url}") + print(f" [PASS] northwest_outdoors URL: {url}") def test_whittler_url_validation(): @@ -34,7 +34,7 @@ def test_whittler_url_validation(): is_valid = bool(url) and "YOUR_LINK" in url and "REMOVED" not in url assert is_valid, f"URL should be valid: {url}" - print(f" ✓ whittler URL: {url}") + print(f" [PASS] whittler URL: {url}") def test_dropbox_url_format(): @@ -46,7 +46,7 @@ def test_dropbox_url_format(): if url and "YOUR_LINK" not in url and "REMOVED" not in url: assert "dropbox.com" in url.lower(), f"Should be Dropbox URL: {url}" assert "scl/fo/" in url, f"Should be shared link format: {url}" - print(f" ✓ {source} has correct Dropbox format") + print(f" [PASS] {source} has correct Dropbox format") def test_missing_urls_handled(): @@ -57,7 +57,7 @@ def test_missing_urls_handled(): is_invalid = not url or "YOUR_LINK" in str(url) assert is_invalid, "Missing URL should be detected" - print(" ✓ Missing URLs handled correctly") + print(" [PASS] Missing URLs handled correctly") def test_partial_urls_config(): @@ -77,7 +77,7 @@ def test_partial_urls_config(): assert is_nwo_valid, "Northwest Outdoors URL should be valid" assert not is_whittler_valid, "Whittler URL should be invalid (not in config)" - print(" ✓ Partial URLs config handled correctly") + print(" [PASS] Partial URLs config handled correctly") def test_url_with_special_characters(): @@ -91,7 +91,7 @@ def test_url_with_special_characters(): is_valid = url and "YOUR_LINK" not in url assert is_valid, f"URL with special chars should be valid: {url}" - print(" ✓ URLs with special characters handled correctly") + print(" [PASS] URLs with special characters handled correctly") def test_validation_logic_matches_northwest_outdoors(): @@ -117,7 +117,7 @@ def test_validation_logic_matches_northwest_outdoors(): actual = bool(url) and "YOUR_LINK" not in str(url) and "REMOVED" not in str(url) assert actual == expected, f"{name}: expected {expected}, got {actual}" - print(" ✓ Northwest Outdoors validation logic matches source") + print(" [PASS] Northwest Outdoors validation logic matches source") def test_validation_logic_matches_whittler(): @@ -143,7 +143,7 @@ def test_validation_logic_matches_whittler(): actual = bool(url) and "YOUR_LINK" not in str(url) and "REMOVED" not in str(url) assert actual == expected, f"{name}: expected {expected}, got {actual}" - print(" ✓ Whittler validation logic matches source") + print(" [PASS] Whittler validation logic matches source") def run_tests(): @@ -171,10 +171,10 @@ def run_tests(): test() passed += 1 except AssertionError as e: - print(f" ✗ {test.__name__}: {e}") + print(f" [FAIL] {test.__name__}: {e}") failed += 1 except Exception as e: - print(f" ✗ {test.__name__}: {e}") + print(f" [FAIL] {test.__name__}: {e}") failed += 1 print("=" * 60) diff --git a/tests/test_sources_unit.py b/tests/test_sources_unit.py index 115c37c..e15fa14 100644 --- a/tests/test_sources_unit.py +++ b/tests/test_sources_unit.py @@ -36,7 +36,7 @@ def test_melinda_myers_url_validation_valid(): downloader = MelindaMyersDownloader(mock_bm, cm) assert downloader is not None, "Should create MelindaMyersDownloader" - print(" ✓ MelindaMyersDownloader instantiates correctly") + print(" [PASS] MelindaMyersDownloader instantiates correctly") def test_melinda_myers_download_browser_failure(): @@ -51,7 +51,7 @@ def test_melinda_myers_download_browser_failure(): with patch.object(downloader, 'wait_for_download_and_get_file', return_value=None): result = downloader.download() assert result is False, "Should return False when browser fails" - print(" ✓ Melinda Myers returns False when browser start fails") + print(" [PASS] Melinda Myers returns False when browser start fails") def test_northwest_outdoors_url_validation(): @@ -64,10 +64,10 @@ def test_northwest_outdoors_url_validation(): is_valid = bool(url) and "YOUR_LINK" not in url and "REMOVED" not in url if url and "YOUR_LINK" not in url: assert is_valid, f"URL should be valid: {url}" - print(f" ✓ Northwest Outdoors has valid URL") + print(f" [PASS] Northwest Outdoors has valid URL") else: assert not is_valid, "Placeholder URL should be invalid" - print(" ✓ Northwest Outdoors correctly has invalid placeholder URL") + print(" [PASS] Northwest Outdoors correctly has invalid placeholder URL") def test_northwest_outdoors_download_browser_failure(): @@ -81,7 +81,7 @@ def test_northwest_outdoors_download_browser_failure(): result = downloader.download() assert result is False, "Should return False when browser fails" - print(" ✓ Northwest Outdoors returns False when browser start fails") + print(" [PASS] Northwest Outdoors returns False when browser start fails") def test_whittler_url_validation(): @@ -92,10 +92,10 @@ def test_whittler_url_validation(): is_valid = bool(url) and "YOUR_LINK" not in url and "REMOVED" not in url if url and "YOUR_LINK" not in url: assert is_valid, f"URL should be valid: {url}" - print(f" ✓ Whittler has valid URL") + print(f" [PASS] Whittler has valid URL") else: assert not is_valid, "Placeholder URL should be invalid" - print(" ✓ Whittler correctly has invalid placeholder URL") + print(" [PASS] Whittler correctly has invalid placeholder URL") def test_whittler_download_browser_failure(): @@ -109,7 +109,7 @@ def test_whittler_download_browser_failure(): result = downloader.download() assert result is False, "Should return False when browser fails" - print(" ✓ Whittler returns False when browser start fails") + print(" [PASS] Whittler returns False when browser start fails") def test_westwood_one_requires_credentials(): @@ -126,7 +126,7 @@ def test_westwood_one_requires_credentials(): result = downloader.download() assert result is False, "Should return False without credentials" - print(" ✓ Westwood One returns False without email/password") + print(" [PASS] Westwood One returns False without email/password") def test_westwood_one_with_credentials(): @@ -146,7 +146,7 @@ def test_westwood_one_with_credentials(): with patch.object(downloader, 'wait_for_download_and_get_file', return_value="/tmp/test.mp3"): result = downloader.download() # It may return True or may fail on other steps, but should not fail on credentials - print(f" ✓ Westwood One with credentials returned: {result}") + print(f" [PASS] Westwood One with credentials returned: {result}") def test_clear_out_west_requires_password(): @@ -162,7 +162,7 @@ def test_clear_out_west_requires_password(): result = downloader.download() assert result is False, "Should return False without cow_password" - print(" ✓ Clear Out West returns False without cow_password") + print(" [PASS] Clear Out West returns False without cow_password") def test_clear_out_west_with_password(): @@ -181,7 +181,7 @@ def test_clear_out_west_with_password(): with patch.object(downloader, 'wait_for_download_and_get_file', return_value="/tmp/test.mp3"): result = downloader.download() # Should proceed past the password check - print(f" ✓ Clear Out West with password returned: {result}") + print(f" [PASS] Clear Out West with password returned: {result}") def test_all_sources_in_factory(): @@ -199,7 +199,7 @@ def test_all_sources_in_factory(): except Exception as e: print(f" Note: Could not create {display_name}: {e}") continue - print(f" ✓ All sources available via factory: {list(DOWNLOAD_SOURCES.keys())}") + print(f" [PASS] All sources available via factory: {list(DOWNLOAD_SOURCES.keys())}") def test_should_auto_close_browser(): @@ -212,7 +212,7 @@ def test_should_auto_close_browser(): downloader = MelindaMyersDownloader(mock_bm, cm) assert downloader.should_auto_close_browser() is True, "Should return True" - print(" ✓ should_auto_close_browser returns config value") + print(" [PASS] should_auto_close_browser returns config value") def test_download_method_signature(): @@ -240,7 +240,7 @@ def test_download_method_signature(): print(f" Note: {display_name}: {e}") continue - print(" ✓ All sources have correct download() signature") + print(" [PASS] All sources have correct download() signature") def run_tests(): @@ -273,10 +273,10 @@ def run_tests(): test() passed += 1 except AssertionError as e: - print(f" ✗ {test.__name__}: {e}") + print(f" [FAIL] {test.__name__}: {e}") failed += 1 except Exception as e: - print(f" ✗ {test.__name__}: {e}") + print(f" [FAIL] {test.__name__}: {e}") failed += 1 print("=" * 60) diff --git a/tests/test_update_checker.py b/tests/test_update_checker.py index d552c84..94cefa0 100644 --- a/tests/test_update_checker.py +++ b/tests/test_update_checker.py @@ -18,15 +18,15 @@ def test_parse_version_valid(): result = checker.parse_version("1.1.8") assert result == (1, 1, 8), f"Expected (1, 1, 8), got {result}" - print(f" ✓ parse_version('1.1.8') = {result}") + print(f" [PASS] parse_version('1.1.8') = {result}") result = checker.parse_version("2.0") assert result == (2, 0), f"Expected (2, 0), got {result}" - print(f" ✓ parse_version('2.0') = {result}") + print(f" [PASS] parse_version('2.0') = {result}") result = checker.parse_version("10.20.30") assert result == (10, 20, 30), f"Expected (10, 20, 30), got {result}" - print(f" ✓ parse_version('10.20.30') = {result}") + print(f" [PASS] parse_version('10.20.30') = {result}") def test_parse_version_invalid(): @@ -47,7 +47,7 @@ def test_parse_version_invalid(): result = checker.parse_version("1.0.0.0") assert result == (1, 0, 0), f"Expected (1, 0, 0) for '1.0.0.0', got {result}" - print(" ✓ parse_version handles invalid versions correctly") + print(" [PASS] parse_version handles invalid versions correctly") def test_parse_version_none(): @@ -60,7 +60,7 @@ def test_parse_version_none(): except (AttributeError, TypeError): # This is acceptable - function may not handle None gracefully pass - print(" ✓ parse_version handles None (may raise or return (0,0,0))") + print(" [PASS] parse_version handles None (may raise or return (0,0,0))") def test_check_for_update_newer_available(): @@ -84,7 +84,7 @@ def test_check_for_update_newer_available(): assert update_available is True, f"Expected update available, got {update_available}" assert version == "2.0.0", f"Expected '2.0.0', got '{version}'" assert "github.com" in url, f"Expected github URL, got '{url}'" - print(f" ✓ Detected newer version: {version}") + print(f" [PASS] Detected newer version: {version}") def test_check_for_update_current_is_latest(): @@ -107,7 +107,7 @@ def test_check_for_update_current_is_latest(): assert update_available is False, "Should report no update available" assert version == "1.1.8", f"Expected '1.1.8', got '{version}'" - print(f" ✓ Correctly reports no update (current is latest)") + print(f" [PASS] Correctly reports no update (current is latest)") def test_check_for_update_network_error(): @@ -120,7 +120,7 @@ def test_check_for_update_network_error(): assert update_available is False, "Should return False on error" assert version == "", f"Expected empty version, got '{version}'" - print(" ✓ Handles network errors gracefully") + print(" [PASS] Handles network errors gracefully") def test_check_for_update_invalid_json(): @@ -138,14 +138,14 @@ def test_check_for_update_invalid_json(): update_available, version, url, notes = checker.check_for_update() assert update_available is False, "Should return False on invalid JSON" - print(" ✓ Handles invalid JSON gracefully") + print(" [PASS] Handles invalid JSON gracefully") def test_latest_version_property(): """Test latest_version property""" checker = UpdateChecker("1.0.0") assert checker.latest_version == "unknown", "Should be 'unknown' before check" - print(" ✓ latest_version is 'unknown' before check") + print(" [PASS] latest_version is 'unknown' before check") def test_download_url_property(): @@ -153,7 +153,7 @@ def test_download_url_property(): checker = UpdateChecker("1.0.0") url = checker.download_url assert "github.com" in url, f"Expected github URL, got '{url}'" - print(f" ✓ download_url: {url}") + print(f" [PASS] download_url: {url}") def test_check_for_updates_async(): @@ -186,7 +186,7 @@ def test_callback(result): result = callback_results[0] assert result[0] is True, "Should detect update" assert result[1] == "2.0.0", f"Expected '2.0.0', got '{result[1]}'" - print(" ✓ Async checker calls callback with correct result") + print(" [PASS] Async checker calls callback with correct result") def run_tests(): @@ -216,10 +216,10 @@ def run_tests(): test() passed += 1 except AssertionError as e: - print(f" ✗ {test.__name__}: {e}") + print(f" [FAIL] {test.__name__}: {e}") failed += 1 except Exception as e: - print(f" ✗ {test.__name__}: {e}") + print(f" [FAIL] {test.__name__}: {e}") failed += 1 print("=" * 60) From d39a1b65ac351c5f7e12652769d8e41012a00db8 Mon Sep 17 00:00:00 2001 From: Bryan Ward Date: Fri, 1 May 2026 16:43:17 -0700 Subject: [PATCH 6/6] fix: replace Unicode marker in run_all_tests.py summary for Windows CI --- run_all_tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/run_all_tests.py b/run_all_tests.py index d74111b..a1ccf40 100644 --- a/run_all_tests.py +++ b/run_all_tests.py @@ -60,7 +60,7 @@ def main(): print("Summary:") print("=" * 60) for test_file, status in results: - marker = "✓" if status == "PASSED" else "✗" + marker = "[PASS]" if status == "PASSED" else "[FAIL]" print(f" {marker} {test_file}: {status}") print()