diff --git a/feeluown/gui/components/player_playlist.py b/feeluown/gui/components/player_playlist.py index d4f83dd9a0..4619c17dc6 100644 --- a/feeluown/gui/components/player_playlist.py +++ b/feeluown/gui/components/player_playlist.py @@ -102,4 +102,15 @@ def scroll_to_current_song(self): def _remove_songs(self, songs): for song in songs: - self._app.playlist.remove(song) + playlist_songs = self._app.playlist.list() + if ( + self._app.playlist.mode is PlaylistMode.fm + # playlist_songs should not be empty, just for robustness + and playlist_songs + and song == self._app.playlist.current_song + and playlist_songs[-1] == song + ): + self._app.show_msg("FM 模式下,如果当前歌曲是最后一首歌,则无法移除,请稍后再尝试移除", timeout=3000) + self._app.playlist.next() + else: + self._app.playlist.remove(song) diff --git a/feeluown/player/playlist.py b/feeluown/player/playlist.py index 9d0faf8259..d4fed7c4b1 100644 --- a/feeluown/player/playlist.py +++ b/feeluown/player/playlist.py @@ -315,8 +315,13 @@ def remove_no_lock(self, song): self._songs.remove(song) new_next_song = self._get_next_song_no_lock() self.set_existing_song_as_current_song(new_next_song) + elif next_song is None and self.mode is PlaylistMode.fm: + # The caller should not remove the current song when it + # is the last song in fm mode. + logger.error("Can't remove the last song in fm mode, will play next") + self._next_no_lock() + return else: - next_song = self._get_next_song_no_lock() self._songs.remove(song) self.set_existing_song_as_current_song(next_song) else: diff --git a/feeluown/serializers/plain.py b/feeluown/serializers/plain.py index 280be781aa..45caf805d5 100644 --- a/feeluown/serializers/plain.py +++ b/feeluown/serializers/plain.py @@ -28,8 +28,8 @@ def _get_items(self, model): # initialize fields that need to be serialized # if as_line option is set, we always use fields_display modelcls = type(model) - fields = [field for field in model.__fields__ - if field not in BaseModel.__fields__] + fields = [field for field in model.model_fields + if field not in BaseModel.model_fields] # Include properties. pydantic_fields = ("__values__", "fields", "__fields_set__", "model_computed_fields", "model_extra", diff --git a/feeluown/uimodels/README.rst b/feeluown/uimodels/README.rst deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/feeluown/uimodels/__init__.py b/feeluown/uimodels/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/feeluown/uimodels/my_music.py b/feeluown/uimodels/my_music.py deleted file mode 100644 index 542a682f2d..0000000000 --- a/feeluown/uimodels/my_music.py +++ /dev/null @@ -1,8 +0,0 @@ -import warnings - -# For backward compat. -from feeluown.gui import MyMusicUiManager # noqa - - -warnings.warn('Please import MyMusicUiManager from feeluown.gui', - DeprecationWarning, stacklevel=2) diff --git a/feeluown/uimodels/playlist.py b/feeluown/uimodels/playlist.py deleted file mode 100644 index 2a750aae57..0000000000 --- a/feeluown/uimodels/playlist.py +++ /dev/null @@ -1,8 +0,0 @@ -import warnings - -# For backward compat. -from feeluown.gui import PlaylistUiManager # noqa - - -warnings.warn('Please import PlaylistUiManager from feeluown.gui', - DeprecationWarning, stacklevel=2) diff --git a/feeluown/uimodels/provider.py b/feeluown/uimodels/provider.py deleted file mode 100644 index 4a4955a15b..0000000000 --- a/feeluown/uimodels/provider.py +++ /dev/null @@ -1,8 +0,0 @@ -import warnings - -# For backward compat. -from feeluown.gui import ProviderUiManager # noqa - - -warnings.warn('Please import ProviderUiManager from feeluown.gui', - DeprecationWarning, stacklevel=2) diff --git a/setup.cfg b/setup.cfg index 00bb23f1ee..aefdcc865f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -27,6 +27,11 @@ test = pytest # ignore DeprecationWarning raised by feeluown filterwarnings = ignore:use feeluown.*?:DeprecationWarning + +# marshmallow may be not installed on some environment +# If you want to ignore the warning in local envinronment, +# you can run pytest like the following:: +# pytest tests/ -W ignore::marshmallow.warnings.RemovedInMarshmallow4Warning addopts = -q --ignore=docs/ --ignore-glob=*/**/mpv*.py @@ -40,6 +45,7 @@ addopts = -q --cov-report= --cov=feeluown --doctest-modules +asyncio_default_fixture_loop_scope = function [mypy-feeluown.mpv] ignore_errors = True diff --git a/tests/app/conftest.py b/tests/app/conftest.py index bc4aa44e82..f7f9291c6d 100644 --- a/tests/app/conftest.py +++ b/tests/app/conftest.py @@ -1,14 +1,18 @@ +import asyncio +from unittest import mock + import pytest +import pytest_asyncio from feeluown.argparser import create_cli_parser from feeluown.app import create_config from feeluown.plugin import PluginsManager from feeluown.collection import CollectionManager from feeluown.utils.dispatch import Signal +from feeluown.player import PlayerPositionDelegate -@pytest.fixture -@pytest.mark.asyncio +@pytest_asyncio.fixture async def signal_aio_support(): Signal.setup_aio_support() yield @@ -41,3 +45,6 @@ def noharm(mocker): mocker.patch('feeluown.app.app.Player') mocker.patch.object(PluginsManager, 'enable_plugins') mocker.patch.object(CollectionManager, 'scan') + # To avoid resource leak:: + # RuntimeWarning: coroutine 'xxx' was never awaited + PlayerPositionDelegate.start = mock.MagicMock(return_value=asyncio.Future()) diff --git a/tests/app/test_app.py b/tests/app/test_app.py index 318b7515e2..32bc9df337 100644 --- a/tests/app/test_app.py +++ b/tests/app/test_app.py @@ -6,8 +6,7 @@ @skip("No easy way to simulate QEventLoop.") -@pytest.mark.asyncio -async def test_create_gui_app(args): +def test_create_gui_app(): pass diff --git a/tests/app/test_server_app.py b/tests/app/test_server_app.py index 1f657fd717..4d968e11b7 100644 --- a/tests/app/test_server_app.py +++ b/tests/app/test_server_app.py @@ -14,6 +14,7 @@ async def test_server_app_initialize(signal_aio_support, args, config, mocker, mock_connect_1 = mocker.patch.object(app.live_lyric.sentence_changed, 'connect') mock_connect_2 = mocker.patch.object(app.player.position_changed, 'connect') mock_connect_3 = mocker.patch.object(app.playlist.song_changed, 'connect') + app.initialize() # live lyric should be initialized properly. diff --git a/tests/entry_points/test_run_app.py b/tests/entry_points/test_run_app.py index 682351ae9c..1f0f8ee1b2 100644 --- a/tests/entry_points/test_run_app.py +++ b/tests/entry_points/test_run_app.py @@ -1,6 +1,6 @@ import asyncio import sys -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, MagicMock import pytest @@ -10,6 +10,7 @@ from feeluown.app import App, AppMode, get_app from feeluown.app.cli_app import CliApp from feeluown.plugin import PluginsManager +from feeluown.version import VersionManager @pytest.fixture @@ -72,6 +73,7 @@ def test_run_app_with_no_window_mode(argsparser, mocker, noqt, noharm): @pytest.mark.asyncio async def test_start_app(argsparser, mocker, noharm): + VersionManager.check_release = MagicMock(return_value=asyncio.Future()) mocker.patch('feeluown.entry_points.run_app.fuoexec_load_rcfile') # fuoexec_init can be only called once, mock it here. mocker.patch('feeluown.entry_points.run_app.fuoexec_init') diff --git a/tests/gui/pages/test_pages.py b/tests/gui/pages/test_pages.py index 9d2f8eaf96..c8d518bf4a 100644 --- a/tests/gui/pages/test_pages.py +++ b/tests/gui/pages/test_pages.py @@ -1,10 +1,16 @@ +import asyncio +from unittest import mock + import pytest +import pytest_asyncio from feeluown.library import BriefArtistModel, BriefAlbumModel, SongModel, \ PlaylistModel from feeluown.utils.router import Request from feeluown.gui.pages.model import render as render_model -from feeluown.gui.pages.song_explore import render as render_song_explore +from feeluown.gui.pages.song_explore import ( + render as render_song_explore, SongWikiLabel, CoverLabelV2, +) from feeluown.gui.page_containers.table import TableContainer from feeluown.gui.uimain.page_view import RightPanel @@ -21,6 +27,14 @@ def guiapp(qtbot, app_mock, library): return app_mock +@pytest_asyncio.fixture +async def no_warning(): + # To avoid such warning:: + # RuntimeWarning: coroutine 'CoverLabelV2.show_cover' was never awaited + SongWikiLabel.show_song = mock.MagicMock(return_value=asyncio.Future()) + CoverLabelV2.show_cover = mock.MagicMock(return_value=asyncio.Future()) + + @pytest.mark.asyncio async def test_render_artist_v2(guiapp, ekaf_provider, ekaf_artist0, ): artistv2 = BriefArtistModel(source=ekaf_provider.identifier, @@ -42,7 +56,7 @@ async def test_render_album_v2(guiapp, ekaf_provider, ekaf_album0, ): @pytest.mark.asyncio -async def test_render_song_v2(guiapp, ekaf_provider): +async def test_render_song_v2(guiapp, ekaf_provider, no_warning): song = SongModel(source=ekaf_provider.identifier, identifier='0', title='', @@ -56,12 +70,14 @@ async def test_render_song_v2(guiapp, ekaf_provider): @pytest.mark.asyncio -async def test_render_song_v2_with_non_exists_album(guiapp, ekaf_provider): +async def test_render_song_v2_with_non_exists_album(guiapp, ekaf_provider, no_warning): """ When the album does not exist, the rendering process should succeed. This test case tests that every exceptions, which raised by library, should be correctly catched. """ + SongWikiLabel.show_song = mock.MagicMock(return_value=asyncio.Future()) + CoverLabelV2.show_cover = mock.MagicMock(return_value=asyncio.Future()) song = SongModel(source=ekaf_provider.identifier, identifier='0', title='', diff --git a/tests/gui/widgets/test_widgets.py b/tests/gui/widgets/test_widgets.py index ca7377b3f7..82919a7d45 100644 --- a/tests/gui/widgets/test_widgets.py +++ b/tests/gui/widgets/test_widgets.py @@ -20,7 +20,7 @@ def test_comment_list_view(qtbot): content=content, time=int(time.time()), parent=brief_comment,) - comment2 = comment.copy() + comment2 = comment.model_copy() comment2.content = 'hello world' reader = create_reader([comment, comment2, comment]) diff --git a/tests/library/test_model_v2.py b/tests/library/test_model_v2.py index 939a4969d8..6bc84675fa 100644 --- a/tests/library/test_model_v2.py +++ b/tests/library/test_model_v2.py @@ -4,14 +4,6 @@ from feeluown.library import SongModel, BriefAlbumModel, BriefArtistModel, BriefSongModel -def test_use_pydantic_from_orm(song): - # Raise a pydantic exception. - # For pydantic v1, pydantic.ConfigError is raised. - # FOr pydantic v2, pydantic.ValidationError is raised. - with pytest.raises(Exception): - BriefSongModel.from_orm(song) - - def test_create_song_model_basic(): identifier = '1' brief_album = BriefAlbumModel(identifier='1', source='x', diff --git a/tests/player/test_playlist.py b/tests/player/test_playlist.py index 00efe8a911..410d8f7aa6 100644 --- a/tests/player/test_playlist.py +++ b/tests/player/test_playlist.py @@ -296,8 +296,13 @@ def feed_playlist(): @pytest.mark.asyncio -async def test_playlist_remove_current_song(app_mock): - pass +async def test_playlist_remove_current_song(pl, song1, mocker): + # Remove the current song, and it is the last song in the playlist. + mock_emit = mocker.patch.object(pl.eof_reached, 'emit') + pl.mode = PlaylistMode.fm + pl._current_song = song1 + pl.remove(song1) + assert mock_emit.called @pytest.mark.asyncio