Files
MoviePilot/tests/test_recommend_chain.py

254 lines
8.9 KiB
Python

import asyncio
from typing import Generator
from unittest.mock import AsyncMock, patch
import pytest
from app.chain.recommend import RecommendChain
from app.core.cache import TTLCache
from app.core.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,
)