test: add TUI test suite (moved from youtube-cli)
This commit is contained in:
parent
6884c1f0ef
commit
96247567d9
235
tests/conftest.py
Normal file
235
tests/conftest.py
Normal file
@ -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
|
||||||
324
tests/integration/test_queue.py
Normal file
324
tests/integration/test_queue.py
Normal file
@ -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
|
||||||
664
tests/integration/test_screens.py
Normal file
664
tests/integration/test_screens.py
Normal file
@ -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
|
||||||
219
tests/unit/test_models.py
Normal file
219
tests/unit/test_models.py
Normal file
@ -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"
|
||||||
340
tests/unit/test_service.py
Normal file
340
tests/unit/test_service.py
Normal file
@ -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"
|
||||||
231
tests/unit/test_widgets.py
Normal file
231
tests/unit/test_widgets.py
Normal file
@ -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
|
||||||
Loading…
x
Reference in New Issue
Block a user