import asyncio from typing import Generator from unittest.mock import AsyncMock, patch import pytest from app.chain.recommend import RecommendChain from app.runtime.cache import TTLCache from app.domain.context import MusicInfo from app.schemas.types import MUSIC_ENTITY_ALBUM SYNC_EMPTY_CACHE_CASES = [ ("tmdb_movies", "app.chain.recommend.TmdbChain", "tmdb_discover"), ("tmdb_tvs", "app.chain.recommend.TmdbChain", "tmdb_discover"), ("tmdb_trending", "app.chain.recommend.TmdbChain", "tmdb_trending"), ("bangumi_calendar", "app.chain.recommend.BangumiChain", "calendar"), ("douban_movie_showing", "app.chain.recommend.DoubanChain", "movie_showing"), ("douban_movies", "app.chain.recommend.DoubanChain", "douban_discover"), ("douban_tvs", "app.chain.recommend.DoubanChain", "douban_discover"), ("douban_movie_top250", "app.chain.recommend.DoubanChain", "movie_top250"), ("douban_tv_weekly_chinese", "app.chain.recommend.DoubanChain", "tv_weekly_chinese"), ("douban_tv_weekly_global", "app.chain.recommend.DoubanChain", "tv_weekly_global"), ("douban_tv_animation", "app.chain.recommend.DoubanChain", "tv_animation"), ("douban_movie_hot", "app.chain.recommend.DoubanChain", "movie_hot"), ("douban_tv_hot", "app.chain.recommend.DoubanChain", "tv_hot"), ] ASYNC_EMPTY_CACHE_CASES = [ ("async_tmdb_movies", "app.chain.recommend.TmdbChain"), ("async_tmdb_tvs", "app.chain.recommend.TmdbChain"), ("async_tmdb_trending", "app.chain.recommend.TmdbChain"), ("async_bangumi_calendar", "app.chain.recommend.BangumiChain"), ("async_douban_movie_showing", "app.chain.recommend.DoubanChain"), ("async_douban_movies", "app.chain.recommend.DoubanChain"), ("async_douban_tvs", "app.chain.recommend.DoubanChain"), ("async_douban_movie_top250", "app.chain.recommend.DoubanChain"), ("async_douban_tv_weekly_chinese", "app.chain.recommend.DoubanChain"), ("async_douban_tv_weekly_global", "app.chain.recommend.DoubanChain"), ("async_douban_tv_animation", "app.chain.recommend.DoubanChain"), ("async_douban_movie_hot", "app.chain.recommend.DoubanChain"), ("async_douban_tv_hot", "app.chain.recommend.DoubanChain"), ] def clear_recommend_cache() -> None: """清理推荐缓存,避免缓存装饰器状态影响用例。""" TTLCache(region=RecommendChain.recommend_cache_region).clear() @pytest.fixture(autouse=True) def isolated_recommend_cache() -> Generator[None, None, None]: """每个用例前后都清空推荐缓存。""" clear_recommend_cache() yield clear_recommend_cache() @pytest.mark.parametrize( ("method_name", "chain_target", "backend_method"), SYNC_EMPTY_CACHE_CASES, ) def test_sync_recommend_methods_do_not_cache_empty_result( method_name: str, chain_target: str, backend_method: str, ) -> None: """同步推荐来源返回空列表时不应缓存。""" chain = RecommendChain() recommend_method = getattr(chain, method_name) with patch(chain_target) as backend_chain: backend_call = getattr(backend_chain.return_value, backend_method) backend_call.side_effect = [[], []] assert recommend_method(page=1) == [] assert recommend_method(page=1) == [] assert backend_call.call_count == 2 @pytest.mark.parametrize(("method_name", "chain_target"), ASYNC_EMPTY_CACHE_CASES) def test_async_recommend_methods_do_not_cache_empty_result( method_name: str, chain_target: str, ) -> None: """异步推荐来源返回空列表时不应缓存。""" chain = RecommendChain() recommend_method = getattr(chain, method_name) with patch(chain_target) as backend_chain: backend_chain.return_value.async_run_module = AsyncMock(side_effect=[[], []]) assert asyncio.run(recommend_method(page=1)) == [] assert asyncio.run(recommend_method(page=1)) == [] assert backend_chain.return_value.async_run_module.call_count == 2 def test_music_weekly_uses_music_chart(): """同步推荐缓存应从本周音乐榜单生成通用媒体字典。""" chain = RecommendChain() with patch("app.chain.recommend.ListenBrainzChain") as source_chain: source_chain.return_value.music_chart.return_value = [ MusicInfo(media_source="musicbrainz", media_id="recording-1", title="晴天") ] result = chain.music_weekly(page=2, count=10) assert result[0]["media_id"] == "recording-1" source_chain.return_value.music_chart.assert_called_once_with( range_name="this_week", page=2, count=10, entity="recording", ) def test_async_music_weekly_uses_music_chart(): """异步推荐接口应从本周音乐榜单返回统一媒体字典。""" chain = RecommendChain() with patch("app.chain.recommend.ListenBrainzChain") as source_chain: source_chain.return_value.async_music_chart = AsyncMock( return_value=[ MusicInfo(media_source="musicbrainz", media_id="recording-1", title="晴天") ] ) result = asyncio.run(chain.async_music_weekly(page=1, count=30)) assert result[0]["type"] == "音乐" source_chain.return_value.async_music_chart.assert_awaited_once_with( range_name="this_week", page=1, count=30, entity="recording", ) def test_music_douban_recommendations_use_discover(): """豆瓣音乐推荐入口应保留来源与实体,并输出统一媒体字典。""" chain = RecommendChain() with patch("app.chain.recommend.DoubanChain") as source_chain: source_chain.return_value.music_discover.return_value = [ MusicInfo( media_source="doubanmusic", media_id="music-1", music_type=MUSIC_ENTITY_ALBUM, title="Music", ) ] result = chain.music_douban(page=2, count=10) assert result[0]["media_source"] == "doubanmusic" source_chain.return_value.music_discover.assert_called_once_with( page=2, count=10, entity=MUSIC_ENTITY_ALBUM, mode="chart", tags="", sort="U", ) def test_async_music_douban_recommendations_use_discover(): """异步豆瓣音乐推荐入口应调用统一发现链并保留来源。""" chain = RecommendChain() with patch("app.chain.recommend.DoubanChain") as source_chain: source_chain.return_value.async_music_discover = AsyncMock( return_value=[ MusicInfo(media_source="doubanmusic", media_id="music-1", title="Music") ] ) result = asyncio.run(chain.async_music_douban(page=1, count=30)) assert result[0]["media_source"] == "doubanmusic" source_chain.return_value.async_music_discover.assert_awaited_once_with( page=1, count=30, entity=MUSIC_ENTITY_ALBUM, mode="chart", tags="", sort="U", ) def test_music_chart_applies_filter_and_sort() -> None: """音乐榜单应在 RecommendChain 统一执行热度、封面和排序约束。""" chain = RecommendChain() candidates = [ MusicInfo( media_source="musicbrainz", media_id="low", title="Low", listen_count=10, cover_url="cover-low", ), MusicInfo( media_source="musicbrainz", media_id="high", title="High", listen_count=30, cover_url="cover-high", ), MusicInfo( media_source="musicbrainz", media_id="no-cover", title="No Cover", listen_count=40, ), ] with patch("app.chain.recommend.ListenBrainzChain") as source_chain: source_chain.return_value.music_chart.return_value = candidates result = chain.music_chart( range_name="this_month", page=1, count=10, sort_by="listen_count.desc", min_listen_count=20, with_cover=True, ) assert [item.media_id for item in result] == ["high"] def test_async_music_fresh_releases_uses_listenbrainz_source() -> None: """新发行推荐应委派 ListenBrainz 来源链并保留分页参数。""" chain = RecommendChain() with patch("app.chain.recommend.ListenBrainzChain") as source_chain: source_chain.return_value.async_music_fresh_releases = AsyncMock( return_value=[ MusicInfo( media_source="musicbrainz", media_id="album-1", music_type=MUSIC_ENTITY_ALBUM, title="Album", ) ] ) result = asyncio.run(chain.async_music_fresh_releases(page=2, count=12)) assert result[0].media_id == "album-1" source_chain.return_value.async_music_fresh_releases.assert_awaited_once_with( days=14, sort="release_date", past=True, future=True, page=2, count=12, )