From 96247567d97145d1ed3e942fe4555706a364f2b4 Mon Sep 17 00:00:00 2001 From: Jarian Cottingham Date: Fri, 21 Aug 2026 17:11:12 +0000 Subject: [PATCH] test: add TUI test suite (moved from youtube-cli) --- tests/conftest.py | 235 +++++++++++ tests/integration/test_queue.py | 324 +++++++++++++++ tests/integration/test_screens.py | 664 ++++++++++++++++++++++++++++++ tests/unit/test_models.py | 219 ++++++++++ tests/unit/test_service.py | 340 +++++++++++++++ tests/unit/test_widgets.py | 231 +++++++++++ 6 files changed, 2013 insertions(+) create mode 100644 tests/conftest.py create mode 100644 tests/integration/test_queue.py create mode 100644 tests/integration/test_screens.py create mode 100644 tests/unit/test_models.py create mode 100644 tests/unit/test_service.py create mode 100644 tests/unit/test_widgets.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e43373c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,235 @@ +""" +Test fixtures and configuration for YouTube TUI tests +""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +# Test fixtures + + +@pytest.fixture +def sample_video_data(): + """Sample video data for testing""" + return { + "video_id": "dQw4w9WgXcQ", + "title": "Rick Astley - Never Gonna Give You Up", + "channel": "RickAstleyVEVO", + "channel_id": "UCuZqHn2U8f4o7bVlY8v8w", + "duration": "3:33", + "view_count": "1000000", + "upload_date": "20091025", + "description": "The official video for Rick Astley's hit song.", + "thumbnail_url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg", + "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "is_short": False, + } + + +@pytest.fixture +def sample_video_object(sample_video_data): + """Sample Video object for testing""" + from youtube_tui.models.video import Video + + return Video(**sample_video_data) + + +@pytest.fixture +def mock_yt_dlp_result(): + """Mock yt-dlp search result""" + return { + "id": "abc123", + "title": "Sample Video", + "author": "Sample Channel", + "channel": "Sample Channel", + "channel_id": "channel123", + "length": "10:30", + "view_count": 150000, + "upload_date": "20240115", + "description": "A sample video description", + "thumbnail": "https://i.ytimg.com/vi/abc123/hqdefault.jpg", + "url": "https://www.youtube.com/watch?v=abc123", + } + + +@pytest.fixture +def mock_youtube_service(): + """Mock YouTubeService for testing""" + from youtube_cli.main import YouTubeCLI + from youtube_tui.services.youtube import YouTubeService + + # Create a real service with mocked cli + with patch.object(YouTubeCLI, "__init__", return_value=None): + service = YouTubeService.__new__(YouTubeService) + service.cli = MagicMock() + service.console = MagicMock() + + # Mock async methods + service.search_videos = AsyncMock() + service.download_video = AsyncMock() + service.download_playlist = AsyncMock() + service.get_categories = AsyncMock() + service.is_video_downloaded = AsyncMock() + service.add_to_archive = AsyncMock() + service.get_archive = AsyncMock() + service.get_downloaded_video_ids = AsyncMock() + service.remove_from_archive = AsyncMock() + service._create_video_from_result = MagicMock() + + return service + + +@pytest.fixture +def mock_video(): + """Mock Video object""" + from youtube_tui.models.video import Video + + video = Video( + video_id="test123", + title="Test Video", + channel="Test Channel", + channel_id="channel123", + duration="5:00", + view_count="1000", + upload_date="20240101", + description="Test description", + thumbnail_url="https://example.com/thumb.jpg", + url="https://www.youtube.com/watch?v=test123", + is_short=False, + ) + return video + + +@pytest.fixture +def mock_search_results(): + """Mock search results""" + from youtube_tui.models.video import Video + + videos = [ + Video( + video_id=f"video{i}", + title=f"Video {i}", + channel=f"Channel {i}", + channel_id=f"channel{i}", + duration=f"{i}:00", + view_count=str(i * 1000), + upload_date="20240101", + description=f"Description {i}", + is_short=(i % 3 == 0), + url=f"https://www.youtube.com/watch?v=video{i}", + ) + for i in range(1, 16) + ] + return videos + + +@pytest.fixture +def app_config(tmp_path): + """Temporary app configuration""" + config = { + "download_dir": str(tmp_path / "downloads"), + "default_locations": [ + str(tmp_path / "downloads"), + str(tmp_path / "movies"), + ], + "max_videos_per_page": 15, + "yt_dlp_args": { + "format": "bestvideo[height=1080]+bestaudio", + }, + "network_share_path": str(tmp_path / "network"), + "default_network_subfolder": "General", + } + return config + + +@pytest.fixture +def mock_app(): + """Mock Textual App for testing screens""" + from textual.app import App + + mock_app = MagicMock(spec=App) + mock_app.screen_stack = [None, None, None] # Simulate screen stack + mock_app.notify = MagicMock() + mock_app.exit = MagicMock() + + # Mock push_screen and pop_screen + mock_app.push_screen = MagicMock() + mock_app.push_results_screen = MagicMock() + mock_app.pop_screen = MagicMock() + mock_app.action_open_search = MagicMock() + mock_app.run_background = MagicMock() + + return mock_app + + +# Async test utilities + + +@pytest.fixture +def event_loop(): + """Create an event loop for async tests""" + loop = asyncio.new_event_loop() + yield loop + loop.close() + + +@pytest.fixture +async def async_mock_search_results(): + """Async mock search results""" + from youtube_tui.models.video import Video + + videos = [ + Video( + video_id=f"video{i}", + title=f"Video {i}", + channel=f"Channel {i}", + channel_id=f"channel{i}", + duration=f"{i}:00", + view_count=str(i * 1000), + upload_date="20240101", + description=f"Description {i}", + is_short=(i % 3 == 0), + url=f"https://www.youtube.com/watch?v=video{i}", + ) + for i in range(1, 16) + ] + return videos + + +# Mock patches + + +@pytest.fixture +def mock_yt_dlp(): + """Patch yt-dlp for testing""" + with patch("youtube_tui.services.youtube.subprocess") as mock_subprocess: + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "2024.01.01" + mock_subprocess.run.return_value = mock_result + yield mock_subprocess + + +@pytest.fixture +def mock_archive(): + """Mock archive data""" + return { + "video123": { + "url": "https://www.youtube.com/watch?v=video123", + "id": "video123", + "title": "Downloaded Video", + } + } + + +@pytest.fixture +def mock_archive_file(tmp_path, mock_archive): + """Create a mock archive file""" + archive_path = tmp_path / "archive.json" + import json + + with open(archive_path, "w") as f: + json.dump(mock_archive, f) + return archive_path diff --git a/tests/integration/test_queue.py b/tests/integration/test_queue.py new file mode 100644 index 0000000..80fb845 --- /dev/null +++ b/tests/integration/test_queue.py @@ -0,0 +1,324 @@ +#!/usr/bin/env python3 +""" +Integration test for the download queue system +""" + +import tempfile +from pathlib import Path +from unittest.mock import patch + +from youtube_tui.models.queue_item import QueueStatus +from youtube_tui.models.video import Video +from youtube_tui.services.queue import DownloadQueue + + +class TestDownloadQueue: + """Tests for the DownloadQueue service""" + + def test_queue_initialization(self): + """Test queue initialization""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create a queue without loading from file + queue = DownloadQueue.__new__(DownloadQueue) + queue._queue = [] + queue._archive_file = Path(tmpdir) / "download_queue.json" + + assert queue.get_stats()["total"] == 0 + + def test_add_video_to_queue(self): + """Test adding a video to the queue""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object( + DownloadQueue, + "_archive_file", + Path(tmpdir) / "download_queue.json", + ): + queue = DownloadQueue() + + # Create a test video + video = Video( + video_id="test123", + title="Test Video", + channel="Test Channel", + channel_id="channel123", + duration="10:00", + view_count="1000", + upload_date="20240101", + description="Test description", + ) + + # Add to queue + item = queue.add_video(video, category="Tech") + assert item.status == QueueStatus.PENDING + assert item.category == "Tech" + assert item.video.video_id == "test123" + + # Check stats + stats = queue.get_stats() + assert stats["total"] == 1 + assert stats["pending"] == 1 + + def test_remove_video_from_queue(self): + """Test removing a video from the queue""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object( + DownloadQueue, + "_archive_file", + Path(tmpdir) / "download_queue.json", + ): + queue = DownloadQueue() + + # Create a test video + video = Video( + video_id="test123", + title="Test Video", + channel="Test Channel", + channel_id="channel123", + duration="10:00", + view_count="1000", + upload_date="20240101", + description="Test description", + ) + + # Add to queue + queue.add_video(video) + + # Remove from queue + removed = queue.remove_video("test123") + assert removed is True + + # Check stats + stats = queue.get_stats() + assert stats["total"] == 0 + + def test_update_status_and_progress(self): + """Test updating status and progress""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object( + DownloadQueue, + "_archive_file", + Path(tmpdir) / "download_queue.json", + ): + queue = DownloadQueue() + + # Create a test video + video = Video( + video_id="test123", + title="Test Video", + channel="Test Channel", + channel_id="channel123", + duration="10:00", + view_count="1000", + upload_date="20240101", + description="Test description", + ) + + # Add to queue + queue.add_video(video) + + # Update status to downloading + queue.update_status( + "test123", QueueStatus.DOWNLOADING, progress=50 + ) + + # Get the item and check status + items = queue.get_queue() + assert len(items) == 1 + assert items[0].status == QueueStatus.DOWNLOADING + assert items[0].progress == 50 + + # Update status to completed + queue.update_status( + "test123", QueueStatus.COMPLETED, progress=100 + ) + + items = queue.get_queue() + assert items[0].status == QueueStatus.COMPLETED + assert items[0].progress == 100 + + def test_cancel_video(self): + """Test cancelling a video in the queue""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object( + DownloadQueue, + "_archive_file", + Path(tmpdir) / "download_queue.json", + ): + queue = DownloadQueue() + + # Create a test video + video = Video( + video_id="test123", + title="Test Video", + channel="Test Channel", + channel_id="channel123", + duration="10:00", + view_count="1000", + upload_date="20240101", + description="Test description", + ) + + # Add to queue + queue.add_video(video) + + # Cancel the video + cancelled = queue.cancel_video("test123") + assert cancelled is True + + # Check status + items = queue.get_queue() + assert items[0].status == QueueStatus.CANCELLED + + def test_get_next_pending(self): + """Test getting the next pending item""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object( + DownloadQueue, + "_archive_file", + Path(tmpdir) / "download_queue.json", + ): + queue = DownloadQueue() + + # Create test videos + video1 = Video( + video_id="test1", + title="Test Video 1", + channel="Test Channel", + channel_id="channel1", + duration="10:00", + view_count="1000", + upload_date="20240101", + description="Test", + ) + video2 = Video( + video_id="test2", + title="Test Video 2", + channel="Test Channel", + channel_id="channel2", + duration="10:00", + view_count="1000", + upload_date="20240101", + description="Test", + ) + + # Add to queue + queue.add_video(video1) + queue.add_video(video2) + + # Get next pending + next_item = queue.get_next_pending() + assert next_item is not None + assert next_item.video.video_id == "test1" + + # Update first item to downloading + queue.update_status("test1", QueueStatus.DOWNLOADING) + + # Get next pending - should be test2 + next_item = queue.get_next_pending() + assert next_item.video.video_id == "test2" + + # Update test2 to downloading + queue.update_status("test2", QueueStatus.DOWNLOADING) + + # No more pending items + next_item = queue.get_next_pending() + assert next_item is None + + def test_clear_completed(self): + """Test clearing completed and cancelled items""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object( + DownloadQueue, + "_archive_file", + Path(tmpdir) / "download_queue.json", + ): + queue = DownloadQueue() + + # Create test videos + video1 = Video( + video_id="test1", + title="Test Video 1", + channel="Test Channel", + channel_id="channel1", + duration="10:00", + view_count="1000", + upload_date="20240101", + description="Test", + ) + video2 = Video( + video_id="test2", + title="Test Video 2", + channel="Test Channel", + channel_id="channel2", + duration="10:00", + view_count="1000", + upload_date="20240101", + description="Test", + ) + video3 = Video( + video_id="test3", + title="Test Video 3", + channel="Test Channel", + channel_id="channel3", + duration="10:00", + view_count="1000", + upload_date="20240101", + description="Test", + ) + + # Add to queue + queue.add_video(video1) # pending + queue.add_video(video2) # pending + + # Mark test1 as completed + queue.update_status("test1", QueueStatus.COMPLETED) + + # Mark test2 as cancelled + queue.cancel_video("test2") + + # Mark test3 as completed + queue.add_video(video3) + queue.update_status("test3", QueueStatus.COMPLETED) + + # Clear completed and cancelled + removed = queue.clear_completed() + assert removed == 3 # All three should be removed + + # Check stats + stats = queue.get_stats() + assert stats["total"] == 0 + + def test_clear_failed(self): + """Test clearing failed items""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object( + DownloadQueue, + "_archive_file", + Path(tmpdir) / "download_queue.json", + ): + queue = DownloadQueue() + + # Create test videos + video1 = Video( + video_id="test1", + title="Test Video 1", + channel="Test Channel", + channel_id="channel1", + duration="10:00", + view_count="1000", + upload_date="20240101", + description="Test", + ) + + # Add to queue and fail it + queue.add_video(video1) + queue.update_status("test1", QueueStatus.FAILED) + + # Clear failed + removed = queue.clear_failed() + assert removed == 1 + + # Check stats + stats = queue.get_stats() + assert stats["total"] == 0 + assert stats["failed"] == 0 diff --git a/tests/integration/test_screens.py b/tests/integration/test_screens.py new file mode 100644 index 0000000..6218e3e --- /dev/null +++ b/tests/integration/test_screens.py @@ -0,0 +1,664 @@ +""" +Integration tests for TUI screens using Textual's testing framework + +Tests are structured to use Textual's App.run_test() which provides: +- A running App instance with proper screen stack +- Async test context +- Headless mode for non-interactive testing +- Pilot object for simulating user interactions +""" + +from unittest.mock import patch + +import pytest + + +class TestSearchScreen: + """Integration tests for SearchScreen using Textual's testing framework""" + + @pytest.fixture + def app(self): + """Create a test app with mocked YouTube service""" + from youtube_tui.app import YouTubeTUI + + # Create app without actually running it + with patch.object(YouTubeTUI, "_check_yt_dlp"): + with patch.object(YouTubeTUI, "load_search_history"): + app = YouTubeTUI() + app._testing = True + yield app + + def test_search_screen_compose(self, app): + """Test search screen composition""" + from youtube_tui.screens.search import SearchScreen + + screen = SearchScreen() + # Compose should yield widgets + widgets = list(screen.compose()) + assert len(widgets) > 0 + assert screen is not None + + async def test_search_action_with_empty_input(self, app): + """Test search action with empty input""" + from youtube_tui.screens.search import SearchScreen + + screen = SearchScreen() + # Use app.run_test() to ensure proper screen mounting + async with app.run_test() as pilot: + app.push_screen(screen) + # Wait for the screen to be fully mounted + await pilot.pause() + + # Mock the update_status method to track calls + update_calls = [] + screen.update_status = lambda message: update_calls.append(message) + + # Simulate pressing Enter with empty input + await pilot.press("enter") + await pilot.pause() + + # Verify that update_status was called (error message) + assert len(update_calls) > 0 + + async def test_search_action_with_valid_input(self, app): + """Test search action with valid input""" + + search_term = "python tutorial" + from youtube_tui.screens.search import SearchScreen + + screen = SearchScreen() + async with app.run_test() as pilot: + app.push_screen(screen) + # Wait for the screen to be fully mounted + await pilot.pause() + + # Type the search term + await pilot.press(*search_term) + await pilot.pause() + + # Press Enter to search + await pilot.press("enter") + await pilot.pause() + + # Get the screen and verify search term was set + screen = app.screen + assert screen.search_term == search_term + + async def test_search_action_from_anywhere(self, app): + """Test search from anywhere action""" + + search_term = "music" + from youtube_tui.screens.search import SearchScreen + + screen = SearchScreen() + async with app.run_test() as pilot: + app.push_screen(screen) + # Wait for the screen to be fully mounted + await pilot.pause() + + # Type and press enter + await pilot.press(*search_term) + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + + # Get the screen and verify search term was set + screen = app.screen + assert screen.search_term == search_term + + async def test_cancel_action(self, app): + """Test cancel action""" + + async with app.run_test() as pilot: + # Wait for the screen to be fully mounted + await pilot.pause() + + # Press Escape to trigger cancel + await pilot.press("escape") + await pilot.pause() + + async def test_go_back_action(self, app): + """Test go back action""" + + async with app.run_test() as pilot: + # Wait for the screen to be fully mounted + await pilot.pause() + + # Press Escape to trigger go_back + await pilot.press("escape") + await pilot.pause() + + async def test_quit_action(self, app): + """Test quit action""" + + async with app.run_test() as pilot: + # Wait for the screen to be fully mounted + await pilot.pause() + + # Press q to quit + await pilot.press("q") + await pilot.pause() + + async def test_button_pressed_search(self, app): + """Test button press for search""" + + async with app.run_test() as pilot: + # Wait for the screen to be fully mounted + await pilot.pause() + + # Press Enter to trigger search + await pilot.press("enter") + await pilot.pause() + + async def test_button_pressed_cancel(self, app): + """Test button press for cancel""" + + async with app.run_test() as pilot: + # Wait for the screen to be fully mounted + await pilot.pause() + + # Press Escape to cancel + await pilot.press("escape") + await pilot.pause() + + async def test_input_submitted(self, app): + """Test input submitted event""" + + async with app.run_test() as pilot: + # Wait for the screen to be fully mounted + await pilot.pause() + + # Type something and press Enter + await pilot.press("t", "e", "s", "t") + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + + +class TestResultsScreen: + """Integration tests for ResultsScreen""" + + @pytest.fixture + def app(self): + """Create a test app with mocked YouTube service""" + from youtube_tui.app import YouTubeTUI + + with patch.object(YouTubeTUI, "_check_yt_dlp"): + with patch.object(YouTubeTUI, "load_search_history"): + app = YouTubeTUI() + app._testing = True + yield app + + def test_results_screen_compose(self, app): + """Test results screen composition""" + from youtube_tui.screens.results import ResultsScreen + + screen = ResultsScreen("test query") + # Compose should yield widgets + widgets = list(screen.compose()) + assert len(widgets) > 0 + assert screen is not None + + async def test_load_results_success(self, app, mock_search_results): + """Test loading results successfully""" + from youtube_tui.screens.results import ResultsScreen + + async with app.run_test() as pilot: + # Wait for the screen to be fully mounted + await pilot.pause() + + # Get the current screen (should be SearchScreen) + # We need to push a ResultsScreen + screen = ResultsScreen("test query") + app.push_screen(screen) + + # Mock the YouTube service to return results + with patch.object( + screen.youtube_service, + "search_videos", + return_value=mock_search_results, + ): + # Wait for the screen to load results + await pilot.pause() + + async def test_load_results_error(self, app): + """Test loading results with error""" + from youtube_tui.screens.results import ResultsScreen + + async with app.run_test() as pilot: + # Wait for the screen to be fully mounted + await pilot.pause() + + # Push a ResultsScreen + screen = ResultsScreen("test query") + app.push_screen(screen) + + # Mock the YouTube service to raise an error + with patch.object( + screen.youtube_service, + "search_videos", + side_effect=Exception("Network error"), + ): + await pilot.pause() + + async def test_update_table(self, app, mock_search_results): + """Test updating the results table""" + + from youtube_tui.screens.results import ResultsScreen + + async with app.run_test() as pilot: + # Wait for the screen to be fully mounted + await pilot.pause() + + # Push a ResultsScreen + screen = ResultsScreen("test query") + app.push_screen(screen) + + # Set videos directly + screen.videos = mock_search_results[:5] + + # Wait for the screen to be fully mounted + await pilot.pause() + + # Now we can update the table + screen.update_table() + await pilot.pause() + + async def test_update_pagination(self, app, mock_search_results): + """Test pagination update""" + from youtube_tui.screens.results import ResultsScreen + + async with app.run_test() as pilot: + # Wait for the screen to be fully mounted + await pilot.pause() + + # Push a ResultsScreen + screen = ResultsScreen("test query") + app.push_screen(screen) + + # Set pagination values + screen.videos = mock_search_results[:10] + screen.page = 1 + screen.total_pages = 2 + + # Wait for the screen to be fully mounted + await pilot.pause() + + # Now we can update pagination + screen.update_pagination() + await pilot.pause() + + async def test_download_action_no_selection(self, app, mock_search_results): + """Test download with no selection""" + from youtube_tui.screens.results import ResultsScreen + + async with app.run_test() as pilot: + # Wait for the screen to be fully mounted + await pilot.pause() + + # Push a ResultsScreen + screen = ResultsScreen("test query") + app.push_screen(screen) + screen.videos = mock_search_results + + # Wait for the screen to be fully mounted + await pilot.pause() + + # Try to download without selecting a row + await pilot.press("enter") + await pilot.pause() + + async def test_download_action_with_selection(self, app, mock_video): + """Test download with video selection""" + from youtube_tui.screens.results import ResultsScreen + + async with app.run_test() as pilot: + # Wait for the screen to be fully mounted + await pilot.pause() + + # Push a ResultsScreen + screen = ResultsScreen("test query") + app.push_screen(screen) + screen.videos = [mock_video] + + # Wait for the screen to be fully mounted + await pilot.pause() + + # Select a row first + await pilot.press("down") + await pilot.pause() + + # Then press enter to download + await pilot.press("enter") + await pilot.pause() + + async def test_next_page_action(self, app, mock_search_results): + """Test next page navigation""" + from youtube_tui.screens.results import ResultsScreen + + async with app.run_test() as pilot: + # Wait for the screen to be fully mounted + await pilot.pause() + + # Push a ResultsScreen + screen = ResultsScreen("test query") + screen.page = 1 + screen.total_pages = 2 + app.push_screen(screen) + + # Mock the service to return different results for each page + def mock_search(search_term, page=1, per_page=15): + if page == 1: + return mock_search_results[:15] + else: + return [] + + with patch.object( + screen.youtube_service, "search_videos", side_effect=mock_search + ): + # Wait for the screen to be fully mounted + await pilot.pause() + + # Press 'n' for next page + await pilot.press("n") + await pilot.pause() + + # Page should have incremented + assert screen.page == 2 + + async def test_previous_page_action(self, app): + """Test previous page navigation""" + from youtube_tui.screens.results import ResultsScreen + + async with app.run_test() as pilot: + # Wait for the screen to be fully mounted + await pilot.pause() + + # Push a ResultsScreen + screen = ResultsScreen("test query") + screen.page = 2 + screen.total_pages = 2 + app.push_screen(screen) + + # Wait for the screen to be fully mounted + await pilot.pause() + + # Press 'p' for previous page + await pilot.press("p") + await pilot.pause() + + # Page should have decremented + assert screen.page == 1 + + async def test_button_pressed_previous(self, app, mock_search_results): + """Test previous button press""" + from youtube_tui.screens.results import ResultsScreen + + async with app.run_test() as pilot: + # Wait for the screen to be fully mounted + await pilot.pause() + + # Push a ResultsScreen + screen = ResultsScreen("test query") + screen.page = 2 + screen.total_pages = 2 + app.push_screen(screen) + + # Wait for the screen to be fully mounted + await pilot.pause() + + # Press 'p' key to go to previous page + await pilot.press("p") + await pilot.pause() + + async def test_button_pressed_next(self, app, mock_search_results): + """Test next button press""" + from youtube_tui.screens.results import ResultsScreen + + async with app.run_test() as pilot: + # Wait for the screen to be fully mounted + await pilot.pause() + + # Push a ResultsScreen + screen = ResultsScreen("test query") + screen.page = 1 + screen.total_pages = 2 + app.push_screen(screen) + + # Wait for the screen to be fully mounted + await pilot.pause() + + # Press 'n' key to go to next page + await pilot.press("n") + await pilot.pause() + + async def test_data_table_row_selected(self, app, mock_video): + """Test data table row selection""" + from youtube_tui.screens.results import ResultsScreen + + async with app.run_test() as pilot: + # Wait for the screen to be fully mounted + await pilot.pause() + + # Push a ResultsScreen + screen = ResultsScreen("test query") + screen.videos = [mock_video] + app.push_screen(screen) + + # Wait for the screen to be fully mounted + await pilot.pause() + + # Select a row + await pilot.press("down") + await pilot.pause() + + # Press enter to trigger row selection + await pilot.press("enter") + await pilot.pause() + + +class TestDownloadScreen: + """Integration tests for DownloadScreen""" + + @pytest.fixture + def app(self): + """Create a test app with mocked YouTube service""" + from youtube_tui.app import YouTubeTUI + + with patch.object(YouTubeTUI, "_check_yt_dlp"): + with patch.object(YouTubeTUI, "load_search_history"): + app = YouTubeTUI() + app._testing = True + yield app + + def test_download_screen_compose(self, app, mock_video): + """Test download screen composition""" + from youtube_tui.screens.download import DownloadScreen + + screen = DownloadScreen(mock_video) + # Compose should yield widgets + widgets = list(screen.compose()) + assert len(widgets) > 0 + assert screen is not None + + async def test_start_download_success(self, app, mock_video): + """Test successful download""" + from youtube_tui.screens.download import DownloadScreen + + async with app.run_test() as pilot: + # Wait for the screen to be fully mounted + await pilot.pause() + + # Push a DownloadScreen + screen = DownloadScreen(mock_video) + app.push_screen(screen) + + # Mock successful download + async def mock_download(video, category): + return True + + with patch.object( + screen.youtube_service, + "download_video", + side_effect=mock_download, + ): + await pilot.pause() + + async def test_start_download_failure(self, app, mock_video): + """Test download failure""" + from youtube_tui.screens.download import DownloadScreen + + async with app.run_test() as pilot: + # Wait for the screen to be fully mounted + await pilot.pause() + + # Push a DownloadScreen + screen = DownloadScreen(mock_video) + app.push_screen(screen) + + # Mock failed download + async def mock_download(video, category): + return False + + with patch.object( + screen.youtube_service, + "download_video", + side_effect=mock_download, + ): + await pilot.pause() + + async def test_start_download_exception(self, app, mock_video): + """Test download with exception""" + from youtube_tui.screens.download import DownloadScreen + + async with app.run_test() as pilot: + # Wait for the screen to be fully mounted + await pilot.pause() + + # Push a DownloadScreen + screen = DownloadScreen(mock_video) + app.push_screen(screen) + + # Mock exception during download + async def mock_download(video, category): + raise Exception("Download failed") + + with patch.object( + screen.youtube_service, + "download_video", + side_effect=mock_download, + ): + await pilot.pause() + + async def test_cancel_download(self, app, mock_video): + """Test download cancellation""" + from youtube_tui.screens.download import DownloadScreen + + async with app.run_test() as pilot: + # Wait for the screen to be fully mounted + await pilot.pause() + + # Push a DownloadScreen + screen = DownloadScreen(mock_video) + app.push_screen(screen) + + # Wait for the screen to be fully mounted + await pilot.pause() + + # Press escape to cancel + await pilot.press("escape") + await pilot.pause() + + async def test_refresh_screen(self, app, mock_video): + """Test refresh screen action""" + from youtube_tui.screens.download import DownloadScreen + + async with app.run_test() as pilot: + # Wait for the screen to be fully mounted + await pilot.pause() + + # Push a DownloadScreen + screen = DownloadScreen(mock_video) + app.push_screen(screen) + + # Wait for the screen to be fully mounted + await pilot.pause() + + # Press ctrl+r to refresh + await pilot.press("ctrl+r") + await pilot.pause() + + async def test_unload_success(self, app, mock_video): + """Test screen unload with success""" + from youtube_tui.screens.download import DownloadScreen + + async with app.run_test() as pilot: + # Wait for the screen to be fully mounted + await pilot.pause() + + # Push a DownloadScreen + screen = DownloadScreen(mock_video) + screen.download_complete = True + app.push_screen(screen) + + await pilot.pause() + + async def test_unload_error(self, app, mock_video): + """Test screen unload with error""" + from youtube_tui.screens.download import DownloadScreen + + async with app.run_test() as pilot: + # Wait for the screen to be fully mounted + await pilot.pause() + + # Push a DownloadScreen + screen = DownloadScreen(mock_video) + screen.download_error = True + app.push_screen(screen) + + await pilot.pause() + + +# Fixtures for test data +@pytest.fixture +def mock_search_results(): + """Mock search results""" + from youtube_tui.models.video import Video + + videos = [ + Video( + video_id=f"video{i}", + title=f"Video {i}", + channel=f"Channel {i}", + channel_id=f"channel{i}", + duration=f"{i}:00", + view_count=str(i * 1000), + upload_date="20240101", + description=f"Description {i}", + is_short=(i % 3 == 0), + url=f"https://www.youtube.com/watch?v=video{i}", + ) + for i in range(1, 16) + ] + return videos + + +@pytest.fixture +def mock_video(): + """Mock Video object""" + from youtube_tui.models.video import Video + + video = Video( + video_id="test123", + title="Test Video", + channel="Test Channel", + channel_id="channel123", + duration="5:00", + view_count="1000", + upload_date="20240101", + description="Test description", + thumbnail_url="https://example.com/thumb.jpg", + url="https://www.youtube.com/watch?v=test123", + is_short=False, + ) + return video diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py new file mode 100644 index 0000000..60508be --- /dev/null +++ b/tests/unit/test_models.py @@ -0,0 +1,219 @@ +""" +Unit tests for Video model +""" + +from youtube_tui.models.video import Video + + +class TestVideoModel: + """Tests for the Video dataclass""" + + def test_video_creation(self, sample_video_data): + """Test creating a Video object from data""" + video = Video(**sample_video_data) + + assert video.video_id == "dQw4w9WgXcQ" + assert video.title == "Rick Astley - Never Gonna Give You Up" + assert video.channel == "RickAstleyVEVO" + assert video.duration == "3:33" + assert video.view_count == "1000000" + assert video.upload_date == "20091025" + assert video.is_short is False + + def test_video_url_generation(self, sample_video_data): + """Test URL is generated from video_id""" + video = Video(**sample_video_data) + + assert video.url == "https://www.youtube.com/watch?v=dQw4w9WgXcQ" + + def test_video_short_detection(self, sample_video_data): + """Test short video detection""" + # Test normal video + video = Video(**sample_video_data) + assert video.is_short is False + + # Test short video via URL + short_data = sample_video_data.copy() + short_data["url"] = "https://www.youtube.com/shorts/dQw4w9WgXcQ" + short_data["is_short"] = False # Reset to test detection + video = Video(**short_data) + assert video.is_short is True + + def test_video_short_detection_duration(self, sample_video_data): + """Test short video detection via duration""" + short_data = sample_video_data.copy() + short_data["duration"] = "0:00" # Shorts have 0:00 duration + video = Video(**short_data) + assert video.is_short is True + + def test_display_title_with_short(self, sample_video_data): + """Test display title for short videos""" + short_data = sample_video_data.copy() + short_data["is_short"] = True + video = Video(**short_data) + + assert ( + video.display_title + == "(short) Rick Astley - Never Gonna Give You Up" + ) + + def test_display_title_without_short(self, sample_video_data): + """Test display title for normal videos""" + video = Video(**sample_video_data) + + assert video.display_title == "Rick Astley - Never Gonna Give You Up" + + def test_display_duration_short(self, sample_video_data): + """Test display duration for short videos""" + short_data = sample_video_data.copy() + short_data["is_short"] = True + video = Video(**short_data) + + assert video.display_duration == "Short" + + def test_display_duration_normal(self, sample_video_data): + """Test display duration for normal videos""" + video = Video(**sample_video_data) + + assert video.display_duration == "3:33" + + def test_to_dict(self, sample_video_data): + """Test Video to_dict conversion""" + video = Video(**sample_video_data) + result = video.to_dict() + + assert result["video_id"] == "dQw4w9WgXcQ" + assert result["title"] == "Rick Astley - Never Gonna Give You Up" + assert result["is_short"] is False + assert result["url"] == "https://www.youtube.com/watch?v=dQw4w9WgXcQ" + + def test_to_dict_default_thumbnail(self, sample_video_data): + """Test to_dict with None thumbnail""" + video = Video(**sample_video_data) + video.thumbnail_url = None + result = video.to_dict() + + assert result["thumbnail_url"] is None + + def test_from_dict(self, sample_video_data): + """Test Video from_dict creation""" + video_dict = { + "video_id": "abc123", + "title": "Test Video", + "channel": "Test Channel", + "channel_id": "channel123", + "duration": "5:00", + "view_count": "1000", + "upload_date": "20240101", + "description": "Test description", + "thumbnail_url": "https://example.com/thumb.jpg", + "url": "https://www.youtube.com/watch?v=abc123", + } + + video = Video.from_dict(video_dict) + + assert video.video_id == "abc123" + assert video.title == "Test Video" + assert video.channel == "Test Channel" + assert video.description == "Test description" + + def test_from_dict_optional_fields(self, sample_video_data): + """Test from_dict with optional fields""" + video_dict = { + "video_id": "abc123", + "title": "Test Video", + "channel": "Test Channel", + "channel_id": "channel123", + "duration": "5:00", + "view_count": "1000", + "upload_date": "20240101", + # description is optional + } + + video = Video.from_dict(video_dict) + + assert video.description == "" + assert video.thumbnail_url is None + + def test_video_equality(self, sample_video_data): + """Test Video equality comparison""" + video1 = Video(**sample_video_data) + video2 = Video(**sample_video_data) + video3 = Video(**{**sample_video_data, "title": "Different Title"}) + + # Dataclass should have automatic equality + assert video1 == video2 + assert video1 != video3 + + def test_video_repr(self, sample_video_data): + """Test Video string representation""" + video = Video(**sample_video_data) + repr_str = repr(video) + + assert "Video" in repr_str + assert "dQw4w9WgXcQ" in repr_str + + def test_video_hash(self, sample_video_data): + """Test Video hash (for set usage)""" + video = Video(**sample_video_data) + + # Dataclass should be hashable (unless frozen=True, then not) + try: + video_hash = hash(video) + assert isinstance(video_hash, int) + except TypeError: + # If not hashable, that's also acceptable for mutable dataclasses + pass + + def test_video_with_custom_url(self, sample_video_data): + """Test Video with custom URL""" + custom_url = "https://youtu.be/dQw4w9WgXcQ" + video_data = sample_video_data.copy() + video_data["url"] = custom_url + + video = Video(**video_data) + + assert video.url == custom_url + # is_short should be detected from URL + assert video.is_short is False # youtu.be doesn't have /shorts/ + + def test_video_short_youtu_be(self, sample_video_data): + """Test short detection with youtu.be URL""" + short_url = "https://youtu.be/dQw4w9WgXcQ?t=0" + video_data = sample_video_data.copy() + video_data["url"] = short_url + video_data["is_short"] = False # Reset to test detection + + # Note: Our detection only checks for /shorts/ in URL, not youtu.be shorts + video = Video(**video_data) + # This should be False since we don't detect youtu.be shorts URLs + assert video.is_short is False + + def test_video_empty_description(self, sample_video_data): + """Test Video with empty description""" + video_data = sample_video_data.copy() + video_data["description"] = "" + + video = Video(**video_data) + + assert video.description == "" + + def test_video_none_values(self, sample_video_data): + """Test Video with None values""" + video_data = { + "video_id": "test123", + "title": "Test", + "channel": "Channel", + "channel_id": "channel123", + "duration": "1:00", + "view_count": "0", + "upload_date": "20240101", + "description": "", + "thumbnail_url": None, + } + + video = Video(**video_data) + + assert video.thumbnail_url is None + assert video.description == "" + assert video.url == "https://www.youtube.com/watch?v=test123" diff --git a/tests/unit/test_service.py b/tests/unit/test_service.py new file mode 100644 index 0000000..4a522b0 --- /dev/null +++ b/tests/unit/test_service.py @@ -0,0 +1,340 @@ +""" +Unit tests for YouTubeService +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +class TestYouTubeService: + """Tests for YouTubeService class""" + + @pytest.fixture + def service(self): + """Create YouTubeService instance""" + from unittest.mock import MagicMock + + from youtube_tui.services.youtube import YouTubeService + + with patch("youtube_cli.main.YouTubeCLI"): + service = YouTubeService.__new__(YouTubeService) + service.cli = MagicMock() + + # Set up format_duration to use the real implementation + def real_format_duration(seconds): + if not seconds: + return "0:00" + hours = int(seconds // 3600) + minutes = int((seconds % 3600) // 60) + secs = int(seconds % 60) + if hours > 0: + return f"{hours}:{minutes:02d}:{secs:02d}" + else: + return f"{minutes}:{secs:02d}" + + service.cli.format_duration = real_format_duration + service.console = MagicMock() + return service + + @pytest.fixture + def mock_yt_dlp_result(self): + """Mock yt-dlp search result""" + return { + "id": "abc123", + "title": "Sample Video", + "author": "Sample Channel", + "channel": "Sample Channel", + "channel_id": "channel123", + "length": "10:30", + "view_count": 150000, + "upload_date": "20240115", + "description": "A sample video description", + "thumbnail": "https://i.ytimg.com/vi/abc123/hqdefault.jpg", + "url": "https://www.youtube.com/watch?v=abc123", + } + + @pytest.mark.asyncio + async def test_search_videos_success(self, service, mock_yt_dlp_result): + """Test successful video search""" + from youtube_tui.models.video import Video + + # Setup mock + service.cli.search_videos.return_value = [mock_yt_dlp_result] + service._create_video_from_result = MagicMock( + return_value=MagicMock(spec=Video) + ) + + # Call the method - patch asyncio.to_thread since that's how it's imported + with patch("asyncio.to_thread") as mock_to_thread: + # The _search function returns a list of Video objects + mock_to_thread.return_value = [mock_yt_dlp_result] + results = await service.search_videos("test query") + + # Verify + assert isinstance(results, list) + + @pytest.mark.asyncio + async def test_search_videos_empty_result(self, service): + """Test search with no results""" + service.cli.search_videos.return_value = [] + + results = await service.search_videos("test query") + + assert results == [] + + @pytest.mark.asyncio + async def test_search_videos_error(self, service): + """Test search error handling""" + service.cli.search_videos.side_effect = Exception("Network error") + service.console.print = MagicMock() + + with pytest.raises(Exception): # SearchError wrapped in asyncio + await service.search_videos("test query") + + @pytest.mark.asyncio + async def test_search_videos_custom_page(self, service, mock_yt_dlp_result): + """Test search with custom page parameter""" + from unittest.mock import MagicMock + + from youtube_tui.models.video import Video + + service.cli.search_videos.return_value = [mock_yt_dlp_result] + # Mock _create_video_from_result to return proper Video objects + service._create_video_from_result = MagicMock( + return_value=MagicMock( + spec=Video, + video_id="abc123", + title="Sample Video", + channel="Sample Channel", + channel_id="channel123", + duration="10:30", + view_count="150000", + upload_date="20240115", + description="A sample video description", + thumbnail_url="https://i.ytimg.com/vi/abc123/hqdefault.jpg", + url="https://www.youtube.com/watch?v=abc123", + is_short=False, + ) + ) + + # Mock asyncio.to_thread to execute the actual _search function + async def mock_to_thread(func, *args, **kwargs): + # Execute the function synchronously + result = func() + return result + + with patch("asyncio.to_thread", mock_to_thread): + await service.search_videos("test query", page=2, per_page=10) + + # Note: per_page is ignored per the implementation + service.cli.search_videos.assert_called_once_with( + "test query", service.cli.config, 2, return_results=True + ) + + @pytest.mark.asyncio + async def test_download_video_success(self, service, mock_video): + """Test successful video download""" + service.cli.download_video.return_value = True + + result = await service.download_video(mock_video, "Music") + + assert result is True + service.cli.download_video.assert_called_once() + + @pytest.mark.asyncio + async def test_download_video_failure(self, service, mock_video): + """Test video download failure""" + service.cli.download_video.return_value = False + service.console.print = MagicMock() + + result = await service.download_video(mock_video, "Music") + + assert result is False + + @pytest.mark.asyncio + async def test_download_video_error(self, service, mock_video): + """Test download error handling""" + service.cli.download_video.side_effect = Exception("Download failed") + service.console.print = MagicMock() + + with pytest.raises(Exception): # DownloadError wrapped in asyncio + await service.download_video(mock_video, "Music") + + @pytest.mark.asyncio + async def test_download_playlist_success(self, service, mock_video): + """Test successful playlist download""" + service.cli.download_playlist.return_value = True + + result = await service.download_playlist(mock_video, "Music") + + assert result is True + service.cli.download_playlist.assert_called_once() + + @pytest.mark.asyncio + async def test_download_playlist_error(self, service, mock_video): + """Test playlist download error handling""" + service.cli.download_playlist.side_effect = Exception("Playlist error") + service.console.print = MagicMock() + + with pytest.raises(Exception): # DownloadError wrapped in asyncio + await service.download_playlist(mock_video, "Music") + + @pytest.mark.asyncio + async def test_get_categories_success(self, service): + """Test getting categories""" + service.cli.get_categories.return_value = ["Music", "Videos", "Movies"] + + categories = await service.get_categories() + + assert categories == ["Music", "Videos", "Movies"] + + @pytest.mark.asyncio + async def test_is_video_downloaded_true(self, service): + """Test checking downloaded video (exists)""" + service.cli.is_video_downloaded.return_value = True + + result = await service.is_video_downloaded("video123") + + assert result is True + + @pytest.mark.asyncio + async def test_is_video_downloaded_false(self, service): + """Test checking downloaded video (not exists)""" + service.cli.is_video_downloaded.return_value = False + + result = await service.is_video_downloaded("video123") + + assert result is False + + @pytest.mark.asyncio + async def test_add_to_archive_success(self, service, mock_video): + """Test adding video to archive""" + service.cli.add_to_archive = MagicMock() + + await service.add_to_archive(mock_video) + + service.cli.add_to_archive.assert_called_once_with( + { + "url": mock_video.url, + "id": mock_video.video_id, + "title": mock_video.title, + } + ) + + @pytest.mark.asyncio + async def test_add_to_archive_error(self, service, mock_video): + """Test archive error handling""" + service.cli.add_to_archive.side_effect = Exception("Archive error") + service.console.print = MagicMock() + + with pytest.raises(Exception): # ArchiveError wrapped in asyncio + await service.add_to_archive(mock_video) + + @pytest.mark.asyncio + async def test_get_archive_success(self, service): + """Test loading archive""" + mock_archive = { + "video123": { + "url": "https://youtube.com/watch?v=video123", + "id": "video123", + } + } + service.cli.load_archive.return_value = mock_archive + + result = await service.get_archive() + + assert result == mock_archive + + @pytest.mark.asyncio + async def test_get_downloaded_video_ids(self, service): + """Test getting downloaded video IDs""" + mock_archive = { + "video1": {"url": "https://youtube.com/watch?v=video1"}, + "video2": {"url": "https://youtube.com/watch?v=video2"}, + } + service.get_archive = AsyncMock(return_value=mock_archive) + + result = await service.get_downloaded_video_ids() + + assert result == {"video1", "video2"} + + @pytest.mark.asyncio + async def test_remove_from_archive_success(self, service): + """Test removing video from archive""" + service.cli.load_archive.return_value = {"video123": {"url": "test"}} + service.cli.save_archive = MagicMock() + + result = await service.remove_from_archive("video123") + + assert result is True + + @pytest.mark.asyncio + async def test_remove_from_archive_not_found(self, service): + """Test removing non-existent video from archive""" + service.cli.load_archive.return_value = {"video123": {"url": "test"}} + service.cli.save_archive = MagicMock() + + result = await service.remove_from_archive("video999") + + assert result is False + service.cli.save_archive.assert_not_called() + + @pytest.mark.asyncio + async def test_remove_from_archive_error(self, service): + """Test archive removal error handling""" + service.cli.load_archive.side_effect = Exception("Archive error") + service.cli.save_archive = MagicMock() + + result = await service.remove_from_archive("video123") + + assert result is False + + def test_create_video_from_result(self, service, mock_yt_dlp_result): + """Test creating Video from yt-dlp result""" + result = service._create_video_from_result(mock_yt_dlp_result) + + assert result.video_id == "abc123" + assert result.title == "Sample Video" + assert result.channel == "Sample Channel" + assert result.duration == "10:30" + assert result.view_count == "150000" + + def test_create_video_from_result_channel_field(self, service): + """Test creating Video with channel field instead of author""" + result_data = { + "id": "abc123", + "title": "Sample Video", + "channel": "Sample Channel", + "channel_id": "channel123", + "length": "5:00", + "view_count": 1000, + "upload_date": "20240101", + "description": "Test", + "thumbnail": "https://example.com/thumb.jpg", + "url": "https://youtube.com/watch?v=abc123", + } + + result = service._create_video_from_result(result_data) + + assert result.channel == "Sample Channel" + + def test_format_duration(self, service): + """Test duration formatting""" + result = service.format_duration(3665) + + # 3665 seconds = 1 hour, 1 minute, 5 seconds + # Format should be HH:MM:SS or MM:SS + assert result in ["1:01:05", "01:01:05"] + + def test_format_duration_minutes(self, service): + """Test duration formatting for minutes""" + result = service.format_duration(125) + + assert result == "2:05" + + def test_format_duration_seconds(self, service): + """Test duration formatting for seconds only""" + result = service.format_duration(30) + + assert result == "0:30" diff --git a/tests/unit/test_widgets.py b/tests/unit/test_widgets.py new file mode 100644 index 0000000..493ab88 --- /dev/null +++ b/tests/unit/test_widgets.py @@ -0,0 +1,231 @@ +""" +Unit tests for TUI widgets +""" + +from datetime import datetime +from unittest.mock import MagicMock, patch + +import pytest + + +class TestStatusBar: + """Tests for StatusBar widget""" + + @pytest.fixture + def mock_app(self): + """Mock Textual app""" + from unittest.mock import MagicMock + + from textual.app import App + + # Create a minimal mock app that works with Textual + app = MagicMock(spec=App) + app.theme = "default" + app.VERSION = "1.0.0" + return app + + def test_statusbar_initialization(self, mock_app): + """Test StatusBar initialization""" + from youtube_tui.widgets.status_bar import StatusBar + + # Mock subprocess.run + with patch("subprocess.run") as mock_run: + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "2024.01.01" + mock_run.return_value = mock_result + + status_bar = StatusBar(mock_app) + + assert status_bar.current_screen == "Search" + assert status_bar.status_message == "Ready" + assert status_bar.downloading is False + assert status_bar.yt_dlp_version == "2024.01.01" + + def test_statusbar_update_version_success(self, mock_app): + """Test version update with successful yt-dlp call""" + from youtube_tui.widgets.status_bar import StatusBar + + with patch("subprocess.run") as mock_run: + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "2024.01.15" + mock_run.return_value = mock_result + + status_bar = StatusBar(mock_app) + status_bar.update_version() + + assert status_bar.yt_dlp_version == "2024.01.15" + + def test_statusbar_update_version_not_installed(self, mock_app): + """Test version update when yt-dlp not installed""" + from youtube_tui.widgets.status_bar import StatusBar + + with patch("subprocess.run") as mock_run: + mock_result = MagicMock() + mock_result.returncode = 1 + mock_run.return_value = mock_result + + status_bar = StatusBar(mock_app) + status_bar.update_version() + + assert status_bar.yt_dlp_version == "not installed" + + def test_statusbar_update_version_error(self, mock_app): + """Test version update with error""" + from youtube_tui.widgets.status_bar import StatusBar + + with patch("subprocess.run") as mock_run: + mock_run.side_effect = Exception("Command failed") + + status_bar = StatusBar(mock_app) + status_bar.update_version() + + assert status_bar.yt_dlp_version == "unknown" + + def test_statusbar_set_screen(self, mock_app): + """Test setting screen name""" + from youtube_tui.widgets.status_bar import StatusBar + + with patch("subprocess.run"): + status_bar = StatusBar(mock_app) + + status_bar.set_screen("Results") + assert status_bar.current_screen == "Results" + + def test_statusbar_set_downloading(self, mock_app): + """Test setting downloading state""" + from youtube_tui.widgets.status_bar import StatusBar + + with patch("subprocess.run"): + status_bar = StatusBar(mock_app) + + status_bar.set_downloading(True) + assert status_bar.downloading is True + + status_bar.set_downloading(False) + assert status_bar.downloading is False + + def test_statusbar_set_status(self, mock_app): + """Test setting status message""" + from youtube_tui.widgets.status_bar import StatusBar + + with patch("subprocess.run"): + status_bar = StatusBar(mock_app) + + status_bar.set_status("Downloading video...") + assert status_bar.status_message == "Downloading video..." + + def test_statusbar_render(self, mock_app): + """Test status bar rendering""" + from datetime import datetime + + from youtube_tui.widgets.status_bar import StatusBar + + with patch("subprocess.run") as mock_run: + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "2024.01.01" + mock_run.return_value = mock_result + + status_bar = StatusBar(mock_app) + status_bar.set_status("Ready") + + # Mock datetime for consistent testing + with patch( + "youtube_tui.widgets.status_bar.datetime" + ) as mock_datetime: + mock_datetime.now.return_value = datetime( + 2024, 1, 15, 12, 30, 0 + ) + render_result = status_bar.render() + + render_str = str(render_result) + assert "Search" in render_str + assert "2024.01.01" in render_str + assert "Ready" in render_str + assert "12:30:00" in render_str + + def test_statusbar_render_downloading(self, mock_app): + """Test status bar rendering with downloading indicator""" + from youtube_tui.widgets.status_bar import StatusBar + + with patch("subprocess.run") as mock_run: + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "2024.01.01" + mock_run.return_value = mock_result + + status_bar = StatusBar(mock_app) + status_bar.set_downloading(True) + + with patch( + "youtube_tui.widgets.status_bar.datetime" + ) as mock_datetime: + mock_datetime.now.return_value = datetime( + 2024, 1, 15, 12, 30, 0 + ) + render_result = status_bar.render() + + render_str = str(render_result) + assert "↓" in render_str # Download indicator + + +class TestCommandPalette: + """Tests for CommandPalette widget""" + + def test_command_palette_creation(self): + """Test CommandPalette initialization""" + from youtube_tui.widgets.command_palette import CommandPalette + + palette = CommandPalette() + assert palette is not None + + +class TestCustomWidgets: + """Tests for custom widget implementations""" + + def test_static_widget_creation(self): + """Test basic Static widget""" + from textual.widgets import Static + + widget = Static("Test content") + assert widget.renderable == "Test content" + + def test_static_widget_with_rich_markup(self): + """Test Static widget with Rich markup""" + from textual.widgets import Static + + widget = Static("[bold]Test[/bold] [red]content[/red]") + # The content should contain the markup + assert "[bold]" in str(widget.renderable) + + def test_button_widget_creation(self): + """Test Button widget""" + from textual.widgets import Button + + button = Button("Click me") + assert str(button.label) == "Click me" + + def test_input_widget_creation(self): + """Test Input widget""" + from textual.widgets import Input + + input_widget = Input(placeholder="Enter text...") + assert input_widget.placeholder == "Enter text..." + + def test_data_table_creation(self): + """Test DataTable widget""" + from textual.widgets import DataTable + + table = DataTable() + assert table is not None + + def test_progress_bar_creation(self): + """Test ProgressBar widget""" + from textual.widgets import ProgressBar + + progress = ProgressBar(total=100) + progress.progress = 50 + assert progress.total == 100 + assert progress.progress == 50