Add comprehensive tests for player API endpoints and player service functionality

- Implemented tests for player API endpoints including play, pause, stop, next, previous, seek, set volume, set mode, reload playlist, and get state.
- Added mock player service for testing API interactions.
- Created tests for player service methods including play, pause, stop playback, next, previous, seek, set volume, set mode, and reload playlist.
- Ensured proper handling of success, error, and edge cases in both API and service tests.
- Verified state management and serialization in player state tests.
This commit is contained in:
JSC
2025-07-30 01:22:24 +02:00
parent 5ed19c8f0f
commit 1b0d291ad3
11 changed files with 2291 additions and 98 deletions

View File

@@ -0,0 +1,623 @@
"""Tests for player service."""
import asyncio
import threading
import time
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from sqlmodel.ext.asyncio.session import AsyncSession
from app.models.sound import Sound
from app.models.sound_played import SoundPlayed
from app.models.user import User
from app.services.player import (
PlayerMode,
PlayerService,
PlayerState,
PlayerStatus,
get_player_service,
initialize_player_service,
shutdown_player_service,
)
class TestPlayerState:
"""Test player state data structure."""
def test_init_creates_default_state(self):
"""Test that player state initializes with default values."""
state = PlayerState()
assert state.status == PlayerStatus.STOPPED
assert state.mode == PlayerMode.CONTINUOUS
assert state.volume == 50
assert state.current_sound_id is None
assert state.current_sound_index is None
assert state.current_sound_position == 0
assert state.current_sound_duration == 0
assert state.current_sound is None
assert state.playlist_id is None
assert state.playlist_name == ""
assert state.playlist_length == 0
assert state.playlist_duration == 0
assert state.playlist_sounds == []
def test_to_dict_serializes_correctly(self):
"""Test that player state serializes to dict correctly."""
state = PlayerState()
state.status = PlayerStatus.PLAYING
state.mode = PlayerMode.LOOP
state.volume = 75
state.current_sound_id = 1
state.current_sound_index = 0
state.current_sound_position = 5000
state.current_sound_duration = 30000
state.playlist_id = 1
state.playlist_name = "Test Playlist"
state.playlist_length = 5
state.playlist_duration = 150000
result = state.to_dict()
assert result["status"] == "playing"
assert result["mode"] == "loop"
assert result["volume"] == 75
assert result["current_sound_id"] == 1
assert result["current_sound_index"] == 0
assert result["current_sound_position"] == 5000
assert result["current_sound_duration"] == 30000
assert result["playlist_id"] == 1
assert result["playlist_name"] == "Test Playlist"
assert result["playlist_length"] == 5
assert result["playlist_duration"] == 150000
def test_serialize_sound_with_sound_object(self):
"""Test serializing a sound object."""
state = PlayerState()
sound = Sound(
id=1,
name="Test Song",
filename="test.mp3",
duration=30000,
size=1024,
type="SDB",
thumbnail="test.jpg",
play_count=5,
)
result = state._serialize_sound(sound)
assert result["id"] == 1
assert result["name"] == "Test Song"
assert result["filename"] == "test.mp3"
assert result["duration"] == 30000
assert result["size"] == 1024
assert result["type"] == "SDB"
assert result["thumbnail"] == "test.jpg"
assert result["play_count"] == 5
def test_serialize_sound_with_none(self):
"""Test serializing None sound."""
state = PlayerState()
result = state._serialize_sound(None)
assert result is None
class TestPlayerService:
"""Test player service functionality."""
@pytest.fixture
def mock_db_session_factory(self):
"""Create a mock database session factory."""
session = AsyncMock(spec=AsyncSession)
return lambda: session
@pytest.fixture
def mock_vlc_instance(self):
"""Create a mock VLC instance."""
with patch("app.services.player.vlc") as mock_vlc:
mock_instance = Mock()
mock_player = Mock()
mock_vlc.Instance.return_value = mock_instance
mock_instance.media_player_new.return_value = mock_player
mock_vlc.State = Mock()
mock_vlc.State.Ended = "ended"
yield mock_vlc
@pytest.fixture
def mock_socket_manager(self):
"""Create a mock socket manager."""
with patch("app.services.player.socket_manager") as mock:
mock.broadcast_to_all = AsyncMock()
yield mock
@pytest.fixture
def player_service(self, mock_db_session_factory, mock_vlc_instance, mock_socket_manager):
"""Create a player service instance for testing."""
service = PlayerService(mock_db_session_factory)
return service
def test_init_creates_player_service(self, mock_db_session_factory, mock_vlc_instance):
"""Test that player service initializes correctly."""
with patch("app.services.player.socket_manager"):
service = PlayerService(mock_db_session_factory)
assert service.db_session_factory is mock_db_session_factory
assert isinstance(service.state, PlayerState)
assert service._vlc_instance is not None
assert service._player is not None
assert service._is_running is False
assert service._position_thread is None
assert service._play_time_tracking == {}
assert isinstance(service._lock, type(threading.Lock()))
assert service._background_tasks == set()
assert service._loop is None
@pytest.mark.asyncio
async def test_start_initializes_service(self, player_service, mock_vlc_instance):
"""Test that start method initializes the service."""
with patch.object(player_service, "reload_playlist", new_callable=AsyncMock):
await player_service.start()
assert player_service._is_running is True
assert player_service._loop is not None
assert player_service._position_thread is not None
assert player_service._position_thread.daemon is True
player_service._player.audio_set_volume.assert_called_once_with(50)
@pytest.mark.asyncio
async def test_stop_cleans_up_service(self, player_service):
"""Test that stop method cleans up the service."""
# Setup initial state
player_service._is_running = True
player_service._position_thread = Mock()
player_service._position_thread.is_alive.return_value = True
with patch.object(player_service, "_stop_playback", new_callable=AsyncMock):
await player_service.stop()
assert player_service._is_running is False
player_service._position_thread.join.assert_called_once_with(timeout=2.0)
player_service._player.release.assert_called_once()
@pytest.mark.asyncio
async def test_play_new_track(self, player_service, mock_vlc_instance):
"""Test playing a new track."""
# Setup test sound
sound = Sound(
id=1,
name="Test Song",
filename="test.mp3",
duration=30000,
size=1024,
type="SDB",
)
player_service.state.playlist_sounds = [sound]
with patch.object(player_service, "_get_sound_file_path") as mock_path:
mock_file_path = Mock(spec=Path)
mock_file_path.exists.return_value = True
mock_path.return_value = mock_file_path
with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock):
mock_media = Mock()
player_service._vlc_instance.media_new.return_value = mock_media
player_service._player.play.return_value = 0 # Success
await player_service.play(0)
assert player_service.state.status == PlayerStatus.PLAYING
assert player_service.state.current_sound == sound
assert player_service.state.current_sound_id == 1
assert player_service.state.current_sound_index == 0
assert 1 in player_service._play_time_tracking
@pytest.mark.asyncio
async def test_play_resume_from_pause(self, player_service):
"""Test resuming playback from pause."""
# Setup paused state
sound = Sound(id=1, name="Test Song", filename="test.mp3", duration=30000)
player_service.state.status = PlayerStatus.PAUSED
player_service.state.current_sound = sound
player_service.state.current_sound_id = 1
player_service.state.current_sound_position = 5000
with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock):
player_service._player.play.return_value = 0 # Success
await player_service.play()
assert player_service.state.status == PlayerStatus.PLAYING
player_service._player.play.assert_called_once()
@pytest.mark.asyncio
async def test_play_invalid_index(self, player_service):
"""Test playing with invalid index raises ValueError."""
player_service.state.playlist_sounds = []
with pytest.raises(ValueError, match="Invalid sound index"):
await player_service.play(0)
@pytest.mark.asyncio
async def test_pause_when_playing(self, player_service):
"""Test pausing when currently playing."""
player_service.state.status = PlayerStatus.PLAYING
with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock):
await player_service.pause()
assert player_service.state.status == PlayerStatus.PAUSED
player_service._player.pause.assert_called_once()
@pytest.mark.asyncio
async def test_pause_when_not_playing(self, player_service):
"""Test pausing when not playing does nothing."""
player_service.state.status = PlayerStatus.STOPPED
with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock) as mock_broadcast:
await player_service.pause()
assert player_service.state.status == PlayerStatus.STOPPED
mock_broadcast.assert_not_called()
@pytest.mark.asyncio
async def test_stop_playback(self, player_service):
"""Test stopping playback."""
player_service.state.status = PlayerStatus.PLAYING
player_service.state.current_sound_position = 5000
with patch.object(player_service, "_process_play_count", new_callable=AsyncMock):
with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock):
await player_service.stop_playback()
assert player_service.state.status == PlayerStatus.STOPPED
assert player_service.state.current_sound_position == 0
player_service._player.stop.assert_called_once()
@pytest.mark.asyncio
async def test_next_track(self, player_service):
"""Test skipping to next track."""
sound1 = Sound(id=1, name="Song 1", filename="song1.mp3")
sound2 = Sound(id=2, name="Song 2", filename="song2.mp3")
player_service.state.playlist_sounds = [sound1, sound2]
player_service.state.current_sound_index = 0
with patch.object(player_service, "play", new_callable=AsyncMock) as mock_play:
await player_service.next()
mock_play.assert_called_once_with(1)
@pytest.mark.asyncio
async def test_previous_track(self, player_service):
"""Test going to previous track."""
sound1 = Sound(id=1, name="Song 1", filename="song1.mp3")
sound2 = Sound(id=2, name="Song 2", filename="song2.mp3")
player_service.state.playlist_sounds = [sound1, sound2]
player_service.state.current_sound_index = 1
with patch.object(player_service, "play", new_callable=AsyncMock) as mock_play:
await player_service.previous()
mock_play.assert_called_once_with(0)
@pytest.mark.asyncio
async def test_seek_position(self, player_service):
"""Test seeking to specific position."""
player_service.state.status = PlayerStatus.PLAYING
player_service.state.current_sound_duration = 30000
with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock):
await player_service.seek(15000)
# Position should be 0.5 (50% of track)
player_service._player.set_position.assert_called_once_with(0.5)
assert player_service.state.current_sound_position == 15000
@pytest.mark.asyncio
async def test_seek_when_stopped(self, player_service):
"""Test seeking when stopped does nothing."""
player_service.state.status = PlayerStatus.STOPPED
with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock) as mock_broadcast:
await player_service.seek(15000)
player_service._player.set_position.assert_not_called()
mock_broadcast.assert_not_called()
@pytest.mark.asyncio
async def test_set_volume(self, player_service):
"""Test setting volume."""
with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock):
await player_service.set_volume(75)
assert player_service.state.volume == 75
player_service._player.audio_set_volume.assert_called_once_with(75)
@pytest.mark.asyncio
async def test_set_volume_clamping(self, player_service):
"""Test volume clamping to valid range."""
with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock):
# Test upper bound
await player_service.set_volume(150)
assert player_service.state.volume == 100
# Test lower bound
await player_service.set_volume(-10)
assert player_service.state.volume == 0
@pytest.mark.asyncio
async def test_set_mode(self, player_service):
"""Test setting playback mode."""
with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock):
await player_service.set_mode(PlayerMode.LOOP)
assert player_service.state.mode == PlayerMode.LOOP
@pytest.mark.asyncio
async def test_reload_playlist(self, player_service):
"""Test reloading playlist from database."""
mock_session = AsyncMock()
player_service.db_session_factory = lambda: mock_session
# Mock playlist repository
with patch("app.services.player.PlaylistRepository") as mock_repo_class:
mock_repo = AsyncMock()
mock_repo_class.return_value = mock_repo
# Mock playlist data
mock_playlist = Mock()
mock_playlist.id = 1
mock_playlist.name = "Test Playlist"
mock_repo.get_main_playlist.return_value = mock_playlist
# Mock sounds
sound1 = Sound(id=1, name="Song 1", filename="song1.mp3", duration=30000)
sound2 = Sound(id=2, name="Song 2", filename="song2.mp3", duration=45000)
mock_sounds = [sound1, sound2]
mock_repo.get_playlist_sounds.return_value = mock_sounds
with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock):
await player_service.reload_playlist()
assert player_service.state.playlist_id == 1
assert player_service.state.playlist_name == "Test Playlist"
assert player_service.state.playlist_sounds == mock_sounds
assert player_service.state.playlist_length == 2
assert player_service.state.playlist_duration == 75000
def test_get_sound_file_path_normalized(self, player_service):
"""Test getting file path for normalized sound."""
sound = Sound(
id=1,
name="Test Song",
filename="original.mp3",
normalized_filename="normalized.mp3",
is_normalized=True,
type="SDB",
)
result = player_service._get_sound_file_path(sound)
expected = Path("sounds/normalized/sdb/normalized.mp3")
assert result == expected
def test_get_sound_file_path_original(self, player_service):
"""Test getting file path for original sound."""
sound = Sound(
id=1,
name="Test Song",
filename="original.mp3",
is_normalized=False,
type="SDB",
)
result = player_service._get_sound_file_path(sound)
expected = Path("sounds/originals/sdb/original.mp3")
assert result == expected
def test_get_sound_file_path_ext_type(self, player_service):
"""Test getting file path for EXT type sound."""
sound = Sound(
id=1,
name="Test Song",
filename="extracted.mp3",
is_normalized=False,
type="EXT",
)
result = player_service._get_sound_file_path(sound)
expected = Path("sounds/originals/extracted/extracted.mp3")
assert result == expected
def test_get_next_index_continuous_mode(self, player_service):
"""Test getting next index in continuous mode."""
player_service.state.mode = PlayerMode.CONTINUOUS
player_service.state.playlist_sounds = [Mock(), Mock(), Mock()]
# Test normal progression
assert player_service._get_next_index(0) == 1
assert player_service._get_next_index(1) == 2
# Test end of playlist
assert player_service._get_next_index(2) is None
def test_get_next_index_loop_mode(self, player_service):
"""Test getting next index in loop mode."""
player_service.state.mode = PlayerMode.LOOP
player_service.state.playlist_sounds = [Mock(), Mock(), Mock()]
# Test normal progression
assert player_service._get_next_index(0) == 1
assert player_service._get_next_index(1) == 2
# Test wrapping to beginning
assert player_service._get_next_index(2) == 0
def test_get_next_index_loop_one_mode(self, player_service):
"""Test getting next index in loop one mode."""
player_service.state.mode = PlayerMode.LOOP_ONE
player_service.state.playlist_sounds = [Mock(), Mock(), Mock()]
# Should always return same index
assert player_service._get_next_index(0) == 0
assert player_service._get_next_index(1) == 1
assert player_service._get_next_index(2) == 2
def test_get_next_index_single_mode(self, player_service):
"""Test getting next index in single mode."""
player_service.state.mode = PlayerMode.SINGLE
player_service.state.playlist_sounds = [Mock(), Mock(), Mock()]
# Should always return None
assert player_service._get_next_index(0) is None
assert player_service._get_next_index(1) is None
assert player_service._get_next_index(2) is None
def test_get_next_index_random_mode(self, player_service):
"""Test getting next index in random mode."""
player_service.state.mode = PlayerMode.RANDOM
player_service.state.playlist_sounds = [Mock(), Mock(), Mock()]
with patch("random.choice") as mock_choice:
mock_choice.return_value = 2
result = player_service._get_next_index(0)
assert result == 2
# Should exclude current index
mock_choice.assert_called_once_with([1, 2])
def test_get_previous_index(self, player_service):
"""Test getting previous index."""
player_service.state.playlist_sounds = [Mock(), Mock(), Mock()]
# Test normal progression
assert player_service._get_previous_index(2) == 1
assert player_service._get_previous_index(1) == 0
# Test beginning without loop
player_service.state.mode = PlayerMode.CONTINUOUS
assert player_service._get_previous_index(0) is None
# Test beginning with loop
player_service.state.mode = PlayerMode.LOOP
assert player_service._get_previous_index(0) == 2
def test_update_play_time(self, player_service):
"""Test updating play time tracking."""
# Setup state
player_service.state.status = PlayerStatus.PLAYING
player_service.state.current_sound_id = 1
player_service.state.current_sound_position = 5000
player_service.state.current_sound_duration = 30000
# Initialize tracking
current_time = time.time()
player_service._play_time_tracking[1] = {
"total_time": 1000,
"last_position": 4000,
"last_update": current_time - 1.0, # 1 second ago
"threshold_reached": False,
}
with patch("app.services.player.time.time", return_value=current_time):
player_service._update_play_time()
tracking = player_service._play_time_tracking[1]
assert tracking["total_time"] == 2000 # Added 1 second (1000ms)
assert tracking["last_position"] == 5000
assert tracking["last_update"] == current_time
@pytest.mark.asyncio
async def test_record_play_count(self, player_service):
"""Test recording play count for a sound."""
mock_session = AsyncMock()
player_service.db_session_factory = lambda: mock_session
# Mock repositories
with patch("app.services.player.SoundRepository") as mock_sound_repo_class:
with patch("app.services.player.UserRepository") as mock_user_repo_class:
mock_sound_repo = AsyncMock()
mock_user_repo = AsyncMock()
mock_sound_repo_class.return_value = mock_sound_repo
mock_user_repo_class.return_value = mock_user_repo
# Mock sound and user
mock_sound = Mock()
mock_sound.play_count = 5
mock_sound_repo.get_by_id.return_value = mock_sound
mock_user = Mock()
mock_user.id = 1
mock_user_repo.get_by_id.return_value = mock_user
# Mock no existing SoundPlayed record
mock_result = Mock()
mock_result.first.return_value = None
mock_session.exec.return_value = mock_result
await player_service._record_play_count(1)
# Verify sound play count was updated
mock_sound_repo.update.assert_called_once_with(
mock_sound, {"play_count": 6}
)
# Verify SoundPlayed record was created
mock_session.add.assert_called_once()
mock_session.commit.assert_called_once()
def test_get_state(self, player_service):
"""Test getting current player state."""
result = player_service.get_state()
assert isinstance(result, dict)
assert "status" in result
assert "mode" in result
assert "volume" in result
class TestPlayerServiceGlobalFunctions:
"""Test global player service functions."""
@pytest.mark.asyncio
async def test_initialize_player_service(self):
"""Test initializing global player service."""
mock_factory = Mock()
with patch("app.services.player.PlayerService") as mock_service_class:
mock_service = AsyncMock()
mock_service_class.return_value = mock_service
await initialize_player_service(mock_factory)
mock_service_class.assert_called_once_with(mock_factory)
mock_service.start.assert_called_once()
@pytest.mark.asyncio
async def test_shutdown_player_service(self):
"""Test shutting down global player service."""
# Mock global player service exists
with patch("app.services.player.player_service") as mock_global:
mock_service = AsyncMock()
mock_global.__bool__ = lambda x: True # Service exists
type(mock_global).__bool__ = lambda x: True
with patch("app.services.player.player_service", mock_service):
await shutdown_player_service()
mock_service.stop.assert_called_once()
def test_get_player_service_success(self):
"""Test getting player service when initialized."""
mock_service = Mock()
with patch("app.services.player.player_service", mock_service):
result = get_player_service()
assert result is mock_service
def test_get_player_service_not_initialized(self):
"""Test getting player service when not initialized."""
with patch("app.services.player.player_service", None):
with pytest.raises(RuntimeError, match="Player service not initialized"):
get_player_service()