Files
archived-MoviePilot/tests/test_subscribe_chain.py

493 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import importlib.util
import sys
import types
from pathlib import Path
from types import SimpleNamespace
from unittest import TestCase
from unittest.mock import patch
from app.schemas.types import MediaType
def _load_subscribe_chain_class():
"""隔离加载 SubscribeChain避免测试依赖完整运行时环境。"""
module_name = "_test_subscribe_chain"
if module_name in sys.modules:
module = sys.modules[module_name]
return module, module.SubscribeChain
injected_modules = {}
def ensure_module(name: str, module: types.ModuleType):
if name in sys.modules:
return sys.modules[name]
sys.modules[name] = module
injected_modules[name] = module
return module
chain_module = ensure_module("app.chain", types.ModuleType("app.chain"))
class _ChainBase:
def __init__(self):
self.messagehelper = SimpleNamespace(put=lambda *args, **kwargs: None)
def post_message(self, *args, **kwargs):
return None
async def async_post_message(self, *args, **kwargs):
return None
chain_module.ChainBase = _ChainBase
interaction_module = ensure_module("app.helper.interaction", types.ModuleType("app.helper.interaction"))
class _SlashInteractionManager:
def create_or_replace(self, *args, **kwargs):
return SimpleNamespace(request_id="request-id")
def get_by_id(self, *args, **kwargs):
return None
def get_by_user(self, *args, **kwargs):
return None
def remove(self, *args, **kwargs):
return None
interaction_module.SlashInteractionManager = _SlashInteractionManager
interaction_module.build_navigation_buttons = lambda *args, **kwargs: []
interaction_module.format_markdown_table = lambda *args, **kwargs: ""
interaction_module.page_items = lambda *args, **kwargs: []
interaction_module.supports_interaction_buttons = lambda *args, **kwargs: False
interaction_module.supports_markdown = lambda *args, **kwargs: False
interaction_module.update_or_post_message = lambda *args, **kwargs: None
config_module = ensure_module("app.core.config", types.ModuleType("app.core.config"))
config_module.global_vars = SimpleNamespace(is_system_stopped=False)
config_module.settings = SimpleNamespace(
RECOGNIZE_SOURCE="themoviedb",
MP_DOMAIN=lambda path: path,
)
context_module = ensure_module("app.core.context", types.ModuleType("app.core.context"))
context_module.TorrentInfo = SimpleNamespace
context_module.Context = SimpleNamespace
context_module.MediaInfo = SimpleNamespace
event_module = ensure_module("app.core.event", types.ModuleType("app.core.event"))
class _EventManager:
@staticmethod
def send_event(*args, **kwargs):
return None
@staticmethod
async def async_send_event(*args, **kwargs):
return None
@staticmethod
def register(*args, **kwargs):
def decorator(func):
return func
return decorator
event_module.eventmanager = _EventManager()
event_module.Event = SimpleNamespace
meta_module = ensure_module("app.core.meta", types.ModuleType("app.core.meta"))
meta_module.MetaBase = SimpleNamespace
metainfo_module = ensure_module("app.core.metainfo", types.ModuleType("app.core.metainfo"))
metainfo_module.MetaInfo = lambda *args, **kwargs: SimpleNamespace(episode_list=[])
words_module = ensure_module("app.core.meta.words", types.ModuleType("app.core.meta.words"))
class _WordsMatcher:
def prepare(self, title, custom_words=None):
return title, []
words_module.WordsMatcher = _WordsMatcher
schemas_module = ensure_module("app.schemas", types.ModuleType("app.schemas"))
class _Notification:
def __init__(self, *args, **kwargs):
self.args = args
self.kwargs = kwargs
class _SubscribeSchema:
def __init__(self, **kwargs):
for key, value in kwargs.items():
setattr(self, key, value)
class _NotExistMediaInfo:
def __init__(self, season=None, episodes=None, total_episode=None, start_episode=None):
self.season = season
self.episodes = episodes or []
self.total_episode = total_episode
self.start_episode = start_episode
class _SubscribeEpisodeInfo:
def __init__(self):
self.downloading = []
self.downloaded = []
self.library = []
class _SubscrbieInfo:
def __init__(self):
self.subscribe = None
self.episodes = {}
class _SubscribeDownloadFileInfo:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
class _SubscribeLibraryFileInfo:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
class _MediaRecognizeConvertEventData:
def __init__(self, **kwargs):
self.mediaid = kwargs.get("mediaid")
self.convert_type = kwargs.get("convert_type")
self.media_dict = kwargs.get("media_dict")
schemas_module.Notification = _Notification
schemas_module.Subscribe = _SubscribeSchema
schemas_module.NotExistMediaInfo = _NotExistMediaInfo
schemas_module.SubscribeEpisodeInfo = _SubscribeEpisodeInfo
schemas_module.SubscrbieInfo = _SubscrbieInfo
schemas_module.SubscribeDownloadFileInfo = _SubscribeDownloadFileInfo
schemas_module.SubscribeLibraryFileInfo = _SubscribeLibraryFileInfo
schemas_module.MediaRecognizeConvertEventData = _MediaRecognizeConvertEventData
logger_module = ensure_module("app.log", types.ModuleType("app.log"))
class _Logger:
def info(self, *args, **kwargs):
return None
def debug(self, *args, **kwargs):
return None
def warning(self, *args, **kwargs):
return None
def warn(self, *args, **kwargs):
return None
def error(self, *args, **kwargs):
return None
logger_module.logger = _Logger()
helper_subscribe_module = ensure_module("app.helper.subscribe", types.ModuleType("app.helper.subscribe"))
class _SubscribeHelper:
def sub_done_async(self, *args, **kwargs):
return None
@staticmethod
def get_shares():
return []
helper_subscribe_module.SubscribeHelper = _SubscribeHelper
helper_torrent_module = ensure_module("app.helper.torrent", types.ModuleType("app.helper.torrent"))
helper_torrent_module.TorrentHelper = type("TorrentHelper", (), {})
db_model_module = ensure_module("app.db.models.subscribe", types.ModuleType("app.db.models.subscribe"))
class _SubscribeModel:
def __init__(self, **kwargs):
for key, value in kwargs.items():
setattr(self, key, value)
def to_dict(self):
return dict(self.__dict__)
db_model_module.Subscribe = _SubscribeModel
subscribe_oper_module = ensure_module("app.db.subscribe_oper", types.ModuleType("app.db.subscribe_oper"))
class _SubscribeOper:
def update(self, *args, **kwargs):
return None
def get(self, *args, **kwargs):
return None
def list(self, *args, **kwargs):
return []
def delete(self, *args, **kwargs):
return None
def add_history(self, *args, **kwargs):
return None
subscribe_oper_module.SubscribeOper = _SubscribeOper
simple_oper_modules = {
"app.db.downloadhistory_oper": "DownloadHistoryOper",
"app.db.site_oper": "SiteOper",
"app.db.systemconfig_oper": "SystemConfigOper",
}
for module_name_key, class_name in simple_oper_modules.items():
module = ensure_module(module_name_key, types.ModuleType(module_name_key))
if class_name == "SystemConfigOper":
class _SystemConfigOper:
def get(self, *args, **kwargs):
return None
def set(self, *args, **kwargs):
return None
setattr(module, class_name, _SystemConfigOper)
else:
setattr(module, class_name, type(class_name, (), {}))
chain_dependencies = {
"app.chain.download": "DownloadChain",
"app.chain.media": "MediaChain",
"app.chain.search": "SearchChain",
"app.chain.tmdb": "TmdbChain",
"app.chain.torrents": "TorrentsChain",
}
for module_name_key, class_name in chain_dependencies.items():
module = ensure_module(module_name_key, types.ModuleType(module_name_key))
setattr(module, class_name, type(class_name, (), {}))
subscribe_path = Path(__file__).resolve().parents[1] / "app" / "chain" / "subscribe.py"
spec = importlib.util.spec_from_file_location(module_name, subscribe_path)
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
assert spec and spec.loader
spec.loader.exec_module(module)
module._injected_modules = injected_modules
return module, module.SubscribeChain
SUBSCRIBE_CHAIN_MODULE, SubscribeChain = _load_subscribe_chain_class()
class SubscribeChainTest(TestCase):
def _build_subscribe(self, **overrides):
data = {
"id": 1,
"name": "Test Show",
"season": 1,
"best_version": 1,
"type": MediaType.TV.value,
"start_episode": 1,
"total_episode": 3,
"current_priority": None,
"episode_priority": None,
"lack_episode": 3,
"state": "R",
"note": [],
"manual_total_episode": 0,
"tmdbid": 1,
"doubanid": None,
"year": "2026",
"imdbid": None,
"tvdbid": None,
"episode_group": None,
"poster": None,
"backdrop": None,
"description": None,
"last_update": None,
"username": None,
"to_dict": lambda: {},
}
data.update(overrides)
return SimpleNamespace(**data)
@staticmethod
def _build_download(priority, selected_episodes=None, meta_episodes=None):
return SimpleNamespace(
torrent_info=SimpleNamespace(pri_order=priority),
selected_episodes=selected_episodes,
meta_info=SimpleNamespace(episode_list=meta_episodes or []),
)
def test_get_episode_priority_falls_back_to_current_priority(self):
subscribe = self._build_subscribe(current_priority=80, episode_priority=None)
self.assertEqual(
SubscribeChain.get_episode_priority(subscribe),
{"1": 80, "2": 80, "3": 80},
)
def test_get_pending_best_version_episodes_uses_per_episode_status(self):
subscribe = self._build_subscribe(
total_episode=5,
episode_priority={"1": 100, "2": 80, "4": 100},
)
self.assertEqual(
SubscribeChain._get_pending_best_version_episodes(subscribe),
[2, 3, 5],
)
def test_best_version_progress_helpers_return_remaining_priority(self):
subscribe = self._build_subscribe(
total_episode=5,
episode_priority={"1": 100, "2": 80, "3": 90, "4": 100, "5": 70},
current_priority=100,
)
self.assertEqual(SubscribeChain.get_best_version_lack_episode(subscribe), 3)
self.assertEqual(SubscribeChain.get_best_version_current_priority(subscribe), 90)
self.assertFalse(SubscribeChain.is_best_version_complete(subscribe))
def test_best_version_progress_helpers_mark_complete_when_all_target_episodes_done(self):
subscribe = self._build_subscribe(
total_episode=3,
episode_priority={"1": 100, "2": 100, "3": 100},
current_priority=90,
)
self.assertEqual(SubscribeChain.get_best_version_lack_episode(subscribe), 0)
self.assertEqual(SubscribeChain.get_best_version_current_priority(subscribe), 100)
self.assertTrue(SubscribeChain.is_best_version_complete(subscribe))
def test_is_episode_range_covered_matches_pending_episodes(self):
subscribe = self._build_subscribe(
total_episode=12,
episode_priority={
**{str(ep): 100 for ep in range(1, 5)},
**{str(ep): 100 for ep in range(8, 13)},
},
)
self.assertTrue(
SubscribeChain._is_episode_range_covered(
meta=SimpleNamespace(episode_list=[5, 6, 7]),
subscribe=subscribe,
)
)
self.assertFalse(
SubscribeChain._is_episode_range_covered(
meta=SimpleNamespace(episode_list=[1, 2, 3, 4]),
subscribe=subscribe,
)
)
self.assertTrue(
SubscribeChain._is_episode_range_covered(
meta=SimpleNamespace(episode_list=[]),
subscribe=subscribe,
)
)
def test_update_subscribe_priority_uses_selected_episodes(self):
subscribe = self._build_subscribe(
total_episode=4,
episode_priority={"1": 100, "2": 80, "3": 70, "4": 60},
current_priority=80,
lack_episode=3,
)
download = self._build_download(
priority=90,
selected_episodes=[3],
meta_episodes=[2, 3, 4],
)
chain = SubscribeChain()
mediainfo = SimpleNamespace(title_year="Test Show (2026)")
with patch.object(SUBSCRIBE_CHAIN_MODULE, "SubscribeOper") as subscribe_oper_cls, patch.object(
SubscribeChain,
"_SubscribeChain__finish_subscribe",
) as finish_mock:
subscribe_oper = subscribe_oper_cls.return_value
subscribe_oper.update.return_value = None
chain.update_subscribe_priority(
subscribe=subscribe,
meta=SimpleNamespace(),
mediainfo=mediainfo,
downloads=[download],
)
subscribe_oper.update.assert_called_once()
payload = subscribe_oper.update.call_args.args[1]
self.assertEqual(payload["episode_priority"], {"1": 100, "2": 80, "3": 90, "4": 60})
self.assertEqual(payload["current_priority"], 90)
self.assertEqual(payload["lack_episode"], 3)
self.assertEqual(subscribe.episode_priority, {"1": 100, "2": 80, "3": 90, "4": 60})
self.assertEqual(subscribe.current_priority, 90)
self.assertEqual(subscribe.lack_episode, 3)
finish_mock.assert_not_called()
def test_update_subscribe_priority_marks_complete_when_all_target_episodes_done(self):
subscribe = self._build_subscribe(
total_episode=3,
episode_priority={"1": 100, "2": 90, "3": 80},
current_priority=90,
lack_episode=2,
)
downloads = [
self._build_download(priority=100, selected_episodes=[2]),
self._build_download(priority=100, selected_episodes=[3]),
]
chain = SubscribeChain()
meta = SimpleNamespace()
mediainfo = SimpleNamespace(title_year="Test Show (2026)")
with patch.object(SUBSCRIBE_CHAIN_MODULE, "SubscribeOper") as subscribe_oper_cls, patch.object(
SubscribeChain,
"_SubscribeChain__finish_subscribe",
) as finish_mock:
subscribe_oper = subscribe_oper_cls.return_value
subscribe_oper.update.return_value = None
chain.update_subscribe_priority(
subscribe=subscribe,
meta=meta,
mediainfo=mediainfo,
downloads=downloads,
)
payload = subscribe_oper.update.call_args.args[1]
self.assertEqual(payload["episode_priority"], {"1": 100, "2": 100, "3": 100})
self.assertEqual(payload["current_priority"], 100)
self.assertEqual(payload["lack_episode"], 0)
finish_mock.assert_called_once_with(subscribe=subscribe, meta=meta, mediainfo=mediainfo)
def test_check_resets_current_priority_when_new_episodes_expand_target_range(self):
subscribe = self._build_subscribe(
total_episode=3,
episode_priority={"1": 100, "2": 100, "3": 100},
current_priority=100,
lack_episode=0,
)
chain = SubscribeChain()
chain.recognize_media = lambda **kwargs: SimpleNamespace(
seasons={1: [1, 2, 3, 4, 5]},
title="Test Show",
year="2026",
vote_average=9.5,
overview="overview",
imdb_id="tt1234567",
tvdb_id=99,
get_poster_image=lambda: "poster",
get_backdrop_image=lambda: "backdrop",
)
with patch.object(SUBSCRIBE_CHAIN_MODULE, "SubscribeOper") as subscribe_oper_cls:
subscribe_oper = subscribe_oper_cls.return_value
subscribe_oper.list.return_value = [subscribe]
subscribe_oper.update.return_value = None
chain.check()
payload = subscribe_oper.update.call_args.args[1]
self.assertEqual(payload["total_episode"], 5)
self.assertEqual(payload["lack_episode"], 2)
self.assertEqual(payload["current_priority"], 0)
self.assertEqual(payload["episode_priority"], {"1": 100, "2": 100, "3": 100, "4": 0, "5": 0})
self.assertEqual(subscribe.total_episode, 5)
self.assertEqual(subscribe.lack_episode, 2)
self.assertEqual(subscribe.current_priority, 0)