test_music_plugin.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. # -*- coding: utf-8 -*-
  2. """
  3. Tests fuer das Music-Plugin.
  4. """
  5. import asyncio
  6. import pytest
  7. from pathlib import Path
  8. from unittest.mock import AsyncMock, MagicMock, patch
  9. from trixy_core.music.track import Track, TrackMetadata, TrackState
  10. from trixy_core.nlp.handler import IntentReceivedData, IntentResult
  11. # =============================================================================
  12. # YouTubeSource Tests
  13. # =============================================================================
  14. class TestYouTubeSource:
  15. """Tests fuer YouTubeSource."""
  16. def _make_source(self, **kwargs):
  17. from plugins.music.sources.youtube import YouTubeSource
  18. return YouTubeSource(
  19. cache_dir=kwargs.get("cache_dir", "/tmp/trixy_test_cache"),
  20. max_cache_size_mb=kwargs.get("max_cache_size_mb", 100),
  21. **{k: v for k, v in kwargs.items()
  22. if k not in ("cache_dir", "max_cache_size_mb")},
  23. )
  24. @pytest.mark.asyncio
  25. async def test_connect_without_yt_dlp(self):
  26. """connect() gibt False zurueck wenn yt-dlp nicht installiert."""
  27. with patch("plugins.music.sources.youtube._YT_DLP_AVAILABLE", False):
  28. source = self._make_source()
  29. result = await source.connect()
  30. assert result is False
  31. assert source.is_available is False
  32. @pytest.mark.asyncio
  33. async def test_connect_with_yt_dlp(self, tmp_path):
  34. """connect() gibt True zurueck wenn yt-dlp verfuegbar."""
  35. with patch("plugins.music.sources.youtube._YT_DLP_AVAILABLE", True):
  36. source = self._make_source(cache_dir=str(tmp_path / "cache"))
  37. result = await source.connect()
  38. assert result is True
  39. assert source.is_available is True
  40. assert (tmp_path / "cache").exists()
  41. @pytest.mark.asyncio
  42. async def test_search_without_yt_dlp(self):
  43. """search() gibt leere Liste zurueck ohne yt-dlp."""
  44. with patch("plugins.music.sources.youtube._YT_DLP_AVAILABLE", False):
  45. source = self._make_source()
  46. results = await source.search("test query")
  47. assert results == []
  48. @pytest.mark.asyncio
  49. async def test_prepare_track_cache_hit(self, tmp_path):
  50. """prepare_track() nutzt Cache wenn vorhanden."""
  51. cache_dir = tmp_path / "cache"
  52. cache_dir.mkdir()
  53. cached_file = cache_dir / "test123.mp3"
  54. cached_file.write_bytes(b"fake audio data")
  55. with patch("plugins.music.sources.youtube._YT_DLP_AVAILABLE", True):
  56. source = self._make_source(cache_dir=str(cache_dir))
  57. await source.connect()
  58. track = Track(
  59. source_type="youtube",
  60. source_id="test123",
  61. extra={"video_id": "test123"},
  62. )
  63. result = await source.prepare_track(track)
  64. assert result is True
  65. assert track.path == cached_file
  66. assert track.state == TrackState.AVAILABLE
  67. def test_parse_title_with_dash(self):
  68. """Parst 'Artist - Title' Format."""
  69. source = self._make_source()
  70. artist, title = source._parse_title("Queen - Bohemian Rhapsody", "QueenVEVO")
  71. assert artist == "Queen"
  72. assert title == "Bohemian Rhapsody"
  73. def test_parse_title_without_dash(self):
  74. """Verwendet Uploader als Artist wenn kein Trennzeichen."""
  75. source = self._make_source()
  76. artist, title = source._parse_title("Bohemian Rhapsody", "QueenVEVO")
  77. assert artist == "QueenVEVO"
  78. def test_parse_title_with_pipe(self):
  79. """Parst 'Artist | Title' Format."""
  80. source = self._make_source()
  81. artist, title = source._parse_title("Queen | Bohemian Rhapsody", "QueenVEVO")
  82. assert artist == "Queen"
  83. assert title == "Bohemian Rhapsody"
  84. @pytest.mark.asyncio
  85. async def test_cleanup_cache(self, tmp_path):
  86. """cleanup_cache() entfernt aelteste Dateien."""
  87. cache_dir = tmp_path / "cache"
  88. cache_dir.mkdir()
  89. # 3 Dateien erstellen (je 1 MB simuliert)
  90. for i in range(3):
  91. f = cache_dir / f"video{i}.mp3"
  92. f.write_bytes(b"x" * (1024 * 1024)) # 1 MB
  93. with patch("plugins.music.sources.youtube._YT_DLP_AVAILABLE", True):
  94. source = self._make_source(
  95. cache_dir=str(cache_dir),
  96. max_cache_size_mb=2, # Max 2 MB → 1 Datei muss weg
  97. )
  98. await source.connect()
  99. removed = await source.cleanup_cache()
  100. assert removed >= 1
  101. @pytest.mark.asyncio
  102. async def test_scan_returns_zero(self):
  103. """scan() gibt immer 0 zurueck (YouTube unterstuetzt kein Scanning)."""
  104. source = self._make_source()
  105. result = await source.scan()
  106. assert result == 0
  107. # =============================================================================
  108. # MusicPlugin Tests
  109. # =============================================================================
  110. class TestMusicPlugin:
  111. """Tests fuer MusicPlugin."""
  112. def _make_plugin(self):
  113. """Erstellt ein MusicPlugin mit Mock-Application."""
  114. mock_app = MagicMock()
  115. mock_app.events = MagicMock()
  116. mock_app.events.emit = AsyncMock()
  117. mock_app.events.trigger = AsyncMock()
  118. # Mock ServiceContainer
  119. mock_service = MagicMock()
  120. mock_service.player = MagicMock()
  121. mock_service.is_playing = False
  122. mock_service.is_paused = False
  123. mock_service.current_track = None
  124. mock_service.play_track = AsyncMock(return_value=True)
  125. mock_service.play_playlist = AsyncMock(return_value=True)
  126. mock_app.services.get_service = MagicMock(return_value=mock_service)
  127. # Mock SatelliteManager
  128. mock_sat = MagicMock()
  129. mock_sat.id = "sat_1"
  130. mock_sat.is_connected = True
  131. mock_sat.sockets.music_out = MagicMock()
  132. mock_app.satellites = MagicMock()
  133. mock_app.satellites.get = MagicMock(return_value=mock_sat)
  134. mock_app.satellites.all = MagicMock(return_value=[mock_sat])
  135. # Mock ServerConfig
  136. mock_app.server_config.assets_directory = "/tmp/assets"
  137. mock_app.server_config.profile = "default"
  138. plugin_path = Path("/tmp/test_music_plugin")
  139. config = {
  140. "enabled": True,
  141. "youtube": {"enabled": False},
  142. "local": {"enabled": False, "music_dirs": [], "scan_on_load": False},
  143. }
  144. from plugins.music.main import MusicPlugin
  145. plugin = MusicPlugin(mock_app, plugin_path, config)
  146. return plugin, mock_app, mock_service
  147. @pytest.mark.asyncio
  148. async def test_handle_play_music_resume(self):
  149. """play_music ohne Query bei pausierter Musik → Resume."""
  150. plugin, mock_app, mock_service = self._make_plugin()
  151. mock_service.is_paused = True
  152. data = IntentReceivedData(
  153. intent="play_music",
  154. slots={},
  155. satellite_id="sat_1",
  156. )
  157. result = await plugin.handle_play_music(data)
  158. assert result.success is True
  159. assert "fortgesetzt" in result.response_text.lower()
  160. mock_app.events.emit.assert_called_once()
  161. @pytest.mark.asyncio
  162. async def test_handle_what_is_playing_no_track(self):
  163. """what_is_playing ohne aktiven Track."""
  164. plugin, _, mock_service = self._make_plugin()
  165. mock_service.current_track = None
  166. data = IntentReceivedData(
  167. intent="what_is_playing",
  168. satellite_id="sat_1",
  169. )
  170. result = await plugin.handle_what_is_playing(data)
  171. assert result.success is True
  172. assert "keine musik" in result.response_text.lower()
  173. @pytest.mark.asyncio
  174. async def test_handle_what_is_playing_with_track(self):
  175. """what_is_playing mit aktivem Track."""
  176. plugin, _, mock_service = self._make_plugin()
  177. mock_service.current_track = Track(
  178. source_type="local",
  179. metadata=TrackMetadata(
  180. title="Bohemian Rhapsody",
  181. artist="Queen",
  182. duration_ms=354000,
  183. ),
  184. )
  185. data = IntentReceivedData(
  186. intent="what_is_playing",
  187. satellite_id="sat_1",
  188. )
  189. result = await plugin.handle_what_is_playing(data)
  190. assert result.success is True
  191. assert "Bohemian Rhapsody" in result.response_text
  192. assert "Queen" in result.response_text
  193. @pytest.mark.asyncio
  194. async def test_handle_next_track(self):
  195. """next_track emittiert music_next Event."""
  196. plugin, mock_app, _ = self._make_plugin()
  197. data = IntentReceivedData(
  198. intent="next_track",
  199. satellite_id="sat_1",
  200. )
  201. result = await plugin.handle_next_track(data)
  202. assert result.success is True
  203. mock_app.events.emit.assert_called_once_with(
  204. "music_next", {"satellite_id": "sat_1"}
  205. )
  206. @pytest.mark.asyncio
  207. async def test_handle_previous_track(self):
  208. """previous_track emittiert music_previous Event."""
  209. plugin, mock_app, _ = self._make_plugin()
  210. data = IntentReceivedData(
  211. intent="previous_track",
  212. satellite_id="sat_1",
  213. )
  214. result = await plugin.handle_previous_track(data)
  215. assert result.success is True
  216. mock_app.events.emit.assert_called_once_with(
  217. "music_previous", {"satellite_id": "sat_1"}
  218. )
  219. @pytest.mark.asyncio
  220. async def test_handle_play_no_service(self):
  221. """play_music gibt Fehler zurueck wenn Service fehlt."""
  222. plugin, mock_app, _ = self._make_plugin()
  223. mock_app.services.get_service = MagicMock(return_value=None)
  224. data = IntentReceivedData(
  225. intent="play_music",
  226. slots={"query": "test"},
  227. satellite_id="sat_1",
  228. )
  229. result = await plugin.handle_play_music(data)
  230. assert result.success is False
  231. @pytest.mark.asyncio
  232. async def test_get_target_satellites(self):
  233. """Ermittelt korrekte Ziel-Satellites."""
  234. plugin, _, _ = self._make_plugin()
  235. targets = plugin._get_target_satellites("sat_1")
  236. assert targets == ["sat_1"]
  237. @pytest.mark.asyncio
  238. async def test_graceful_degradation_no_youtube(self):
  239. """Plugin funktioniert ohne yt-dlp."""
  240. plugin, _, _ = self._make_plugin()
  241. # YouTube deaktiviert in Config
  242. await plugin.on_load()
  243. assert plugin._youtube is None
  244. # Plugin sollte trotzdem geladen sein
  245. await plugin.on_unload()