mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-30 20:05:42 +08:00
797 lines
25 KiB
Python
797 lines
25 KiB
Python
"""
|
|
搜索链同步、异步与流式入口的共享业务决策回归测试。
|
|
"""
|
|
|
|
import asyncio
|
|
from copy import deepcopy
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from app.chain.search import execution as execution_module
|
|
from app.chain.search import media as media_module
|
|
from app.chain.search import title as title_module
|
|
from app.chain.search.facade import SearchChain
|
|
from app.domain.context import MediaInfo, TorrentInfo
|
|
from app.domain.metainfo import MetaInfo
|
|
from app.schemas.types import MediaSource, MediaType
|
|
|
|
|
|
def _media() -> MediaInfo:
|
|
"""
|
|
构造具备稳定来源身份和别名的测试媒体。
|
|
"""
|
|
return MediaInfo(
|
|
media_source=MediaSource.TMDB,
|
|
media_id="100",
|
|
tmdb_id=100,
|
|
title="测试电影",
|
|
names=["Test Movie"],
|
|
type=MediaType.MOVIE,
|
|
year="2026",
|
|
)
|
|
|
|
|
|
def _torrent(title: str) -> TorrentInfo:
|
|
"""
|
|
构造一条无需外部 provider 的测试资源。
|
|
"""
|
|
return TorrentInfo(title=title, description="description")
|
|
|
|
|
|
def _chain() -> SearchChain:
|
|
"""
|
|
绕过运行时组合根构造可局部注入的搜索链。
|
|
"""
|
|
return object.__new__(SearchChain)
|
|
|
|
|
|
def test_id_search_sync_async_share_request_and_missing_plan(monkeypatch):
|
|
"""
|
|
ID 搜索双入口应产生完全相同的识别、缓存和缺集参数。
|
|
"""
|
|
chain = _chain()
|
|
target = _media()
|
|
sync_calls: list[dict[str, Any]] = []
|
|
async_calls: list[dict[str, Any]] = []
|
|
saved_sync: list[dict[str, Any]] = []
|
|
saved_async: list[dict[str, Any]] = []
|
|
result = [SimpleNamespace(name="context")]
|
|
|
|
class FakeMediaChain:
|
|
"""
|
|
记录同步与异步识别参数并返回同一媒体快照。
|
|
"""
|
|
|
|
def recognize_media(self, **kwargs):
|
|
"""
|
|
返回同步识别结果。
|
|
"""
|
|
sync_calls.append(kwargs)
|
|
return target
|
|
|
|
async def async_recognize_media(self, **kwargs):
|
|
"""
|
|
返回异步识别结果。
|
|
"""
|
|
async_calls.append(kwargs)
|
|
return target
|
|
|
|
def process(**kwargs):
|
|
"""
|
|
记录同步处理计划。
|
|
"""
|
|
sync_calls.append(kwargs)
|
|
return result
|
|
|
|
async def async_process(**kwargs):
|
|
"""
|
|
记录异步处理计划。
|
|
"""
|
|
async_calls.append(kwargs)
|
|
return result
|
|
|
|
async def async_save_params(**kwargs):
|
|
"""
|
|
记录异步缓存参数。
|
|
"""
|
|
saved_async.append(kwargs)
|
|
|
|
async def async_save_results(_contexts):
|
|
"""
|
|
模拟异步结果缓存。
|
|
"""
|
|
|
|
monkeypatch.setattr(media_module, "MediaChain", FakeMediaChain)
|
|
chain.cancel_ai_recommend = lambda: None
|
|
chain.save_last_search_params = lambda **kwargs: saved_sync.append(kwargs)
|
|
chain.async_save_last_search_params = async_save_params
|
|
chain.process = process
|
|
chain.async_process = async_process
|
|
chain._save_results = lambda _contexts: None
|
|
chain._async_save_results = async_save_results
|
|
|
|
kwargs = {
|
|
"media_source": MediaSource.TMDB,
|
|
"media_id": "100",
|
|
"mtype": MediaType.TV,
|
|
"area": "imdbid",
|
|
"season": 2,
|
|
"sites": [1, 3],
|
|
"cache_local": True,
|
|
"music_type": "recording",
|
|
}
|
|
sync_result = chain.search_by_id(**kwargs)
|
|
async_result = asyncio.run(chain.async_search_by_id(**kwargs))
|
|
|
|
assert sync_result is result
|
|
assert async_result is result
|
|
assert sync_calls[0] == async_calls[0] == {
|
|
"media_source": MediaSource.TMDB,
|
|
"media_id": "100",
|
|
"mtype": MediaType.TV,
|
|
"music_type": "recording",
|
|
}
|
|
assert sync_calls[1] == async_calls[1]
|
|
assert sync_calls[1]["sites"] == [1, 3]
|
|
assert sync_calls[1]["area"] == "imdbid"
|
|
missing = sync_calls[1]["no_exists"]
|
|
assert list(missing) == ["tmdb:100"]
|
|
assert list(missing["tmdb:100"]) == [2]
|
|
assert saved_sync == saved_async
|
|
|
|
|
|
def test_imdb_search_falls_back_to_media_title_without_imdb_id():
|
|
"""
|
|
IMDb 搜索缺少 IMDb ID 且没有调用方关键字时应请求媒体标题。
|
|
"""
|
|
target = _media()
|
|
|
|
assert SearchChain._torrent_keyword(None, target, "imdbid") == target.title
|
|
assert SearchChain._torrent_keyword("自定义标题", target, "imdbid") == "自定义标题"
|
|
|
|
|
|
def test_media_process_sync_async_share_keyword_stop_decision(monkeypatch):
|
|
"""
|
|
双入口应按相同关键字顺序搜索,并在首个有效结果后同时停止。
|
|
"""
|
|
chain = _chain()
|
|
target = _media()
|
|
found = _torrent("测试电影 2026 1080p")
|
|
sync_keywords: list[str] = []
|
|
async_keywords: list[str] = []
|
|
sync_sleeps: list[int] = []
|
|
async_sleeps: list[int] = []
|
|
parsed: list[list[TorrentInfo]] = []
|
|
|
|
class FakeMediaChain:
|
|
"""
|
|
为同步与异步处理提供等价的附加信息结果。
|
|
"""
|
|
|
|
def supplement_media_info(self, mediainfo):
|
|
"""
|
|
返回同步附加信息。
|
|
"""
|
|
return mediainfo
|
|
|
|
async def async_supplement_media_info(self, mediainfo):
|
|
"""
|
|
返回异步附加信息。
|
|
"""
|
|
return mediainfo
|
|
|
|
def search_all_sites(**kwargs):
|
|
"""
|
|
第二个关键字开始返回资源。
|
|
"""
|
|
keyword = kwargs["keyword"]
|
|
sync_keywords.append(keyword)
|
|
return [found] if keyword == "second" else []
|
|
|
|
async def async_search_all_sites(**kwargs):
|
|
"""
|
|
第二个关键字开始异步返回资源。
|
|
"""
|
|
keyword = kwargs["keyword"]
|
|
async_keywords.append(keyword)
|
|
return [found] if keyword == "second" else []
|
|
|
|
def parse_result(**kwargs):
|
|
"""
|
|
记录进入统一结果处理边界的资源顺序。
|
|
"""
|
|
parsed.append(list(kwargs["torrents"]))
|
|
return kwargs["torrents"]
|
|
|
|
async def fake_async_sleep(delay):
|
|
"""
|
|
记录异步关键字退避但不实际等待。
|
|
"""
|
|
async_sleeps.append(delay)
|
|
|
|
monkeypatch.setattr(media_module, "MediaChain", FakeMediaChain)
|
|
monkeypatch.setattr(execution_module.random, "randint", lambda _start, _end: 1)
|
|
monkeypatch.setattr(execution_module.time, "sleep", sync_sleeps.append)
|
|
monkeypatch.setattr(execution_module.asyncio, "sleep", fake_async_sleep)
|
|
chain.runtime_config = SimpleNamespace(search_multiple_name=False)
|
|
chain._copy_media_input = deepcopy
|
|
chain._prepare_params = lambda **_kwargs: (None, ["first", "second", "third"])
|
|
chain._SearchChain__search_all_sites = search_all_sites
|
|
chain._SearchChain__async_search_all_sites = async_search_all_sites
|
|
chain._parse_result = parse_result
|
|
|
|
sync_result = chain.process(target)
|
|
async_result = asyncio.run(chain.async_process(target))
|
|
|
|
assert sync_keywords == async_keywords == ["first", "second"]
|
|
assert sync_sleeps == async_sleeps == [1]
|
|
assert parsed == [[found], [found]]
|
|
assert sync_result == async_result == [found]
|
|
|
|
|
|
def test_keyword_resolution_searches_all_names_when_enabled(monkeypatch):
|
|
"""
|
|
开启多名称搜索时同步、异步共用状态机须完整执行并稳定聚合结果。
|
|
"""
|
|
chain = _chain()
|
|
target = _media()
|
|
first = _torrent("First Result")
|
|
third = _torrent("Third Result")
|
|
results = {"first": [first], "second": [], "third": [third]}
|
|
sync_keywords = []
|
|
async_keywords = []
|
|
|
|
def search(**kwargs):
|
|
"""
|
|
记录同步站点调用顺序。
|
|
"""
|
|
sync_keywords.append(kwargs["keyword"])
|
|
return results[kwargs["keyword"]]
|
|
|
|
async def async_search(**kwargs):
|
|
"""
|
|
记录异步站点调用顺序。
|
|
"""
|
|
async_keywords.append(kwargs["keyword"])
|
|
return results[kwargs["keyword"]]
|
|
|
|
async def supplement(mediainfo):
|
|
"""
|
|
异步补充步骤在样例中不改变媒体对象。
|
|
"""
|
|
return mediainfo
|
|
|
|
async def sleep(_delay):
|
|
"""
|
|
隔离关键词间隔,不执行实际等待。
|
|
"""
|
|
|
|
provider = SimpleNamespace(supplement_media_info=lambda mediainfo: mediainfo,
|
|
async_supplement_media_info=supplement)
|
|
monkeypatch.setattr(media_module, "MediaChain", lambda: provider)
|
|
monkeypatch.setattr(execution_module.time, "sleep", lambda _delay: None)
|
|
monkeypatch.setattr(execution_module.asyncio, "sleep", sleep)
|
|
chain.runtime_config = SimpleNamespace(search_multiple_name=True)
|
|
chain._prepare_params = lambda **_kwargs: (None, list(results))
|
|
chain._SearchChain__search_all_sites = search
|
|
chain._SearchChain__async_search_all_sites = async_search
|
|
chain._parse_result = lambda **kwargs: list(kwargs["torrents"])
|
|
|
|
assert chain.process(target) == [first, third]
|
|
assert asyncio.run(chain.async_process(target)) == [first, third]
|
|
assert sync_keywords == async_keywords == list(results)
|
|
|
|
|
|
def test_media_process_stream_shares_keyword_order_and_stop_decision(monkeypatch):
|
|
"""
|
|
流入口应复用双入口的关键字顺序、退避次数和首结果短路决策。
|
|
"""
|
|
chain = _chain()
|
|
target = _media()
|
|
found = _torrent("测试电影 2026 1080p")
|
|
stream_keywords: list[str] = []
|
|
sleeps: list[int] = []
|
|
parsed: list[list[TorrentInfo]] = []
|
|
|
|
class FakeMediaChain:
|
|
"""
|
|
为流处理提供无需外部服务的附加信息结果。
|
|
"""
|
|
|
|
async def async_supplement_media_info(self, mediainfo):
|
|
"""
|
|
返回异步附加信息。
|
|
"""
|
|
return mediainfo
|
|
|
|
async def search_stream(**kwargs):
|
|
"""
|
|
第二个关键字输出资源,供共享状态机执行短路。
|
|
"""
|
|
keyword = kwargs["keyword"]
|
|
stream_keywords.append(keyword)
|
|
yield {
|
|
"site_id": len(stream_keywords),
|
|
"items": [found] if keyword == "second" else [],
|
|
}
|
|
|
|
async def fake_async_sleep(delay):
|
|
"""
|
|
记录流关键字退避但不实际等待。
|
|
"""
|
|
sleeps.append(delay)
|
|
|
|
def parse_result(**kwargs):
|
|
"""
|
|
记录流最终过滤前的资源顺序并返回空上下文。
|
|
"""
|
|
parsed.append(list(kwargs["torrents"]))
|
|
return []
|
|
|
|
async def collect_events():
|
|
"""
|
|
完整消费媒体搜索流。
|
|
"""
|
|
return [
|
|
event
|
|
async for event in chain.async_process_stream(mediainfo=target)
|
|
]
|
|
|
|
monkeypatch.setattr(media_module, "MediaChain", FakeMediaChain)
|
|
monkeypatch.setattr(execution_module.random, "randint", lambda _start, _end: 1)
|
|
monkeypatch.setattr(execution_module.asyncio, "sleep", fake_async_sleep)
|
|
chain.runtime_config = SimpleNamespace(search_multiple_name=False)
|
|
chain._copy_media_input = deepcopy
|
|
chain._prepare_params = lambda **_kwargs: (None, ["first", "second", "third"])
|
|
chain._SearchChain__async_search_all_sites_stream = search_stream
|
|
chain._parse_result = parse_result
|
|
|
|
events = asyncio.run(collect_events())
|
|
|
|
assert stream_keywords == ["first", "second", "third"]
|
|
assert sleeps == [1, 1]
|
|
assert parsed == [[found]]
|
|
assert [event["type"] for event in events] == [
|
|
"append",
|
|
"append",
|
|
"progress",
|
|
"append",
|
|
"replace",
|
|
"done",
|
|
]
|
|
assert events[-1]["candidate_items"] == 1
|
|
|
|
|
|
def test_media_process_empty_keyword_plan_skips_all_provider_io(monkeypatch):
|
|
"""
|
|
空关键字计划应由共享状态机直接完成,且三入口均不触发 provider。
|
|
"""
|
|
chain = _chain()
|
|
target = _media()
|
|
parsed: list[list[TorrentInfo]] = []
|
|
|
|
class FakeMediaChain:
|
|
"""
|
|
为三入口提供稳定的媒体附加信息。
|
|
"""
|
|
|
|
def supplement_media_info(self, mediainfo):
|
|
"""
|
|
返回同步附加信息。
|
|
"""
|
|
return mediainfo
|
|
|
|
async def async_supplement_media_info(self, mediainfo):
|
|
"""
|
|
返回异步附加信息。
|
|
"""
|
|
return mediainfo
|
|
|
|
def unexpected_provider(**_kwargs):
|
|
"""
|
|
标记空计划错误触发了同步 provider。
|
|
"""
|
|
raise AssertionError("空关键字计划不应触发同步 provider")
|
|
|
|
async def unexpected_async_provider(**_kwargs):
|
|
"""
|
|
标记空计划错误触发了异步 provider。
|
|
"""
|
|
raise AssertionError("空关键字计划不应触发异步 provider")
|
|
|
|
async def unexpected_stream_provider(**_kwargs):
|
|
"""
|
|
标记空计划错误触发了流式 provider。
|
|
"""
|
|
yield {"items": []}
|
|
raise AssertionError("空关键字计划不应触发流式 provider")
|
|
|
|
def parse_result(**kwargs):
|
|
"""
|
|
记录三入口交付给结果解析边界的空资源。
|
|
"""
|
|
parsed.append(list(kwargs["torrents"]))
|
|
return []
|
|
|
|
async def collect_events():
|
|
"""
|
|
完整消费空关键字媒体搜索流。
|
|
"""
|
|
return [
|
|
event
|
|
async for event in chain.async_process_stream(mediainfo=target)
|
|
]
|
|
|
|
monkeypatch.setattr(media_module, "MediaChain", FakeMediaChain)
|
|
chain.runtime_config = SimpleNamespace(search_multiple_name=False)
|
|
chain._copy_media_input = deepcopy
|
|
chain._prepare_params = lambda **_kwargs: (None, [])
|
|
chain._SearchChain__search_all_sites = unexpected_provider
|
|
chain._SearchChain__async_search_all_sites = unexpected_async_provider
|
|
chain._SearchChain__async_search_all_sites_stream = unexpected_stream_provider
|
|
chain._parse_result = parse_result
|
|
|
|
assert chain.process(target) == []
|
|
assert asyncio.run(chain.async_process(target)) == []
|
|
events = asyncio.run(collect_events())
|
|
|
|
assert parsed == [[], [], []]
|
|
assert [event["type"] for event in events] == [
|
|
"progress",
|
|
"replace",
|
|
"done",
|
|
]
|
|
assert events[-1]["candidate_items"] == 0
|
|
|
|
|
|
def test_id_search_stream_failure_preserves_order_and_skips_result_cache(
|
|
monkeypatch,
|
|
):
|
|
"""
|
|
识别失败时流入口应先保存请求状态,只输出错误且不保存空结果。
|
|
"""
|
|
chain = _chain()
|
|
order: list[str] = []
|
|
|
|
class FakeMediaChain:
|
|
"""
|
|
模拟异步识别失败。
|
|
"""
|
|
|
|
async def async_recognize_media(self, **_kwargs):
|
|
"""
|
|
记录识别顺序并返回失败。
|
|
"""
|
|
order.append("recognize")
|
|
return None
|
|
|
|
async def save_params(**_kwargs):
|
|
"""
|
|
记录请求状态保存顺序。
|
|
"""
|
|
order.append("params")
|
|
|
|
async def save_results(_contexts):
|
|
"""
|
|
若失败路径错误保存结果则留下可断言记录。
|
|
"""
|
|
order.append("results")
|
|
|
|
async def collect_events():
|
|
"""
|
|
完整消费识别失败的搜索流。
|
|
"""
|
|
return [
|
|
event
|
|
async for event in chain.async_search_by_id_stream(
|
|
media_source=MediaSource.TMDB,
|
|
media_id="404",
|
|
cache_local=True,
|
|
)
|
|
]
|
|
|
|
monkeypatch.setattr(media_module, "MediaChain", FakeMediaChain)
|
|
chain.cancel_ai_recommend = lambda: order.append("cancel")
|
|
chain.async_save_last_search_params = save_params
|
|
chain._async_save_results = save_results
|
|
|
|
events = asyncio.run(collect_events())
|
|
|
|
assert order == ["cancel", "params", "recognize"]
|
|
assert events == [
|
|
{"type": "error", "success": False, "message": "媒体信息识别失败"}
|
|
]
|
|
|
|
|
|
def test_title_search_sync_async_share_plan_filter_and_projection():
|
|
"""
|
|
标题搜索双入口应共享 provider 参数、过滤决策和可替换元数据投影。
|
|
"""
|
|
chain = _chain()
|
|
keep = _torrent("Movie 2026 1080p")
|
|
drop = _torrent("Movie 2026 2160p")
|
|
provider_calls: list[dict[str, Any]] = []
|
|
filter_calls: list[list[TorrentInfo]] = []
|
|
meta_calls: list[str] = []
|
|
|
|
def search_all_sites(**kwargs):
|
|
"""
|
|
记录同步 provider 参数。
|
|
"""
|
|
provider_calls.append(kwargs)
|
|
return [keep, drop]
|
|
|
|
async def async_search_all_sites(**kwargs):
|
|
"""
|
|
记录异步 provider 参数。
|
|
"""
|
|
provider_calls.append(kwargs)
|
|
return [keep, drop]
|
|
|
|
def filter_torrents(torrents, rule_groups=None):
|
|
"""
|
|
记录共享过滤输入并保留首条资源。
|
|
"""
|
|
assert rule_groups == ["quality"]
|
|
filter_calls.append(list(torrents))
|
|
return [keep]
|
|
|
|
def build_meta(torrent, _mtype):
|
|
"""
|
|
模拟插件或测试替换的稳定元数据构造点。
|
|
"""
|
|
meta_calls.append(torrent.title)
|
|
return MetaInfo(title=f"mapped:{torrent.title}")
|
|
|
|
chain._SearchChain__search_all_sites = search_all_sites
|
|
chain._SearchChain__async_search_all_sites = async_search_all_sites
|
|
chain._filter_title_search_torrents = filter_torrents
|
|
chain._build_title_search_meta = build_meta
|
|
|
|
sync_result = chain.search_by_title(
|
|
title="Movie", page=2, sites=[1], mtype=MediaType.MOVIE, rule_groups=["quality"]
|
|
)
|
|
async_result = asyncio.run(
|
|
chain.async_search_by_title(
|
|
title="Movie",
|
|
page=2,
|
|
sites=[1],
|
|
mtype=MediaType.MOVIE,
|
|
rule_groups=["quality"],
|
|
)
|
|
)
|
|
|
|
assert provider_calls == [
|
|
{"keyword": "Movie", "sites": [1], "page": 2, "mtype": MediaType.MOVIE},
|
|
{"keyword": "Movie", "sites": [1], "page": 2, "mtype": MediaType.MOVIE},
|
|
]
|
|
assert filter_calls == [[keep, drop], [keep, drop]]
|
|
assert meta_calls == [keep.title, keep.title]
|
|
assert [context.meta_info.title for context in sync_result] == [
|
|
context.meta_info.title for context in async_result
|
|
] == [f"mapped:{keep.title}"]
|
|
|
|
|
|
def test_title_search_sync_async_share_empty_result_decisions(monkeypatch):
|
|
"""
|
|
标题双入口应共享无候选与过滤为空的警告,并跳过结果缓存。
|
|
"""
|
|
chain = _chain()
|
|
candidate = _torrent("Movie 2026 1080p")
|
|
provider_result: list[TorrentInfo] = []
|
|
warnings: list[str] = []
|
|
filter_calls: list[list[TorrentInfo]] = []
|
|
saved_results: list[list[Any]] = []
|
|
|
|
def search_all_sites(**_kwargs):
|
|
"""
|
|
返回当前场景的同步候选快照。
|
|
"""
|
|
return list(provider_result)
|
|
|
|
async def async_search_all_sites(**_kwargs):
|
|
"""
|
|
返回当前场景的异步候选快照。
|
|
"""
|
|
return list(provider_result)
|
|
|
|
def filter_torrents(torrents, rule_groups=None):
|
|
"""
|
|
记录过滤输入并模拟候选全部被规则排除。
|
|
"""
|
|
assert rule_groups == ["quality"]
|
|
filter_calls.append(list(torrents))
|
|
return []
|
|
|
|
async def save_params(**_kwargs):
|
|
"""
|
|
模拟异步请求参数缓存。
|
|
"""
|
|
|
|
async def save_results(contexts):
|
|
"""
|
|
记录不应发生的异步结果缓存。
|
|
"""
|
|
saved_results.append(list(contexts))
|
|
|
|
monkeypatch.setattr(title_module.logger, "warning", warnings.append)
|
|
chain._SearchChain__search_all_sites = search_all_sites
|
|
chain._SearchChain__async_search_all_sites = async_search_all_sites
|
|
chain._filter_title_search_torrents = filter_torrents
|
|
chain._build_title_search_meta = lambda torrent, _mtype: MetaInfo(
|
|
title=torrent.title
|
|
)
|
|
chain.cancel_ai_recommend = lambda: None
|
|
chain.save_last_search_params = lambda **_kwargs: None
|
|
chain.async_save_last_search_params = save_params
|
|
chain._save_results = lambda contexts: saved_results.append(list(contexts))
|
|
chain._async_save_results = save_results
|
|
|
|
for torrents, warning in (
|
|
([], "Movie 未搜索到资源"),
|
|
([candidate], "Movie 没有符合过滤规则的资源"),
|
|
):
|
|
provider_result[:] = torrents
|
|
warning_count = len(warnings)
|
|
assert chain.search_by_title(
|
|
title="Movie",
|
|
cache_local=True,
|
|
rule_groups=["quality"],
|
|
) == []
|
|
assert asyncio.run(
|
|
chain.async_search_by_title(
|
|
title="Movie",
|
|
cache_local=True,
|
|
rule_groups=["quality"],
|
|
)
|
|
) == []
|
|
assert warnings[warning_count:] == [warning, warning]
|
|
|
|
assert filter_calls == [[candidate], [candidate]]
|
|
assert saved_results == []
|
|
|
|
|
|
def test_title_stream_preserves_provider_and_result_order():
|
|
"""
|
|
标题流应按 provider 批次顺序追加,并以相同顺序发布累计完成结果。
|
|
"""
|
|
chain = _chain()
|
|
first = _torrent("Movie 2026 1080p")
|
|
second = _torrent("Movie 2026 2160p")
|
|
provider_params: list[dict[str, Any]] = []
|
|
|
|
async def search_stream(**kwargs):
|
|
"""
|
|
按固定顺序输出两个 provider 批次。
|
|
"""
|
|
provider_params.append(kwargs)
|
|
yield {"type": "append", "site_id": 1, "items": [first]}
|
|
yield {"type": "append", "site_id": 2, "items": [second]}
|
|
|
|
async def collect_events():
|
|
"""
|
|
完整消费标题搜索流。
|
|
"""
|
|
return [
|
|
event
|
|
async for event in chain.async_search_by_title_stream(
|
|
title="Movie", sites=[1, 2], rule_groups=[]
|
|
)
|
|
]
|
|
|
|
chain._SearchChain__async_search_all_sites_stream = search_stream
|
|
chain._filter_title_search_torrents = lambda torrents, rule_groups=None: torrents
|
|
chain._build_title_search_meta = (
|
|
lambda torrent, _mtype: MetaInfo(title=torrent.title)
|
|
)
|
|
|
|
events = asyncio.run(collect_events())
|
|
|
|
assert provider_params == [
|
|
{"keyword": "Movie", "sites": [1, 2], "page": 0, "mtype": None}
|
|
]
|
|
assert [event["type"] for event in events] == ["append", "append", "done"]
|
|
assert [event["total_items"] for event in events] == [1, 2, 2]
|
|
assert [
|
|
item["torrent_info"]["title"] for item in events[-1]["items"]
|
|
] == [first.title, second.title]
|
|
assert events[-1]["candidate_items"] == 2
|
|
|
|
|
|
def test_id_search_resolution_freezes_success_and_failure_effect_order():
|
|
"""
|
|
ID 搜索状态机应唯一决定成功保存顺序与识别失败短路。
|
|
"""
|
|
recognition_params = {"media_source": MediaSource.TMDB, "media_id": "100"}
|
|
cache_params = {**recognition_params, "sites": [1]}
|
|
contexts = [SimpleNamespace(name="context")]
|
|
|
|
success = media_module._id_search_resolution(
|
|
recognition_params=recognition_params,
|
|
cache_params=cache_params,
|
|
season=2,
|
|
sites=[1],
|
|
area="title",
|
|
cache_local=True,
|
|
failure_keyword="tmdb:100",
|
|
)
|
|
assert isinstance(next(success), media_module._IdSearchCacheRequest)
|
|
assert isinstance(success.send(None), media_module._IdSearchRecognizeRequest)
|
|
process_request = success.send(_media())
|
|
assert isinstance(process_request, media_module._IdSearchProcessRequest)
|
|
assert list(process_request.params["no_exists"]["tmdb:100"]) == [2]
|
|
save_request = success.send(contexts)
|
|
assert isinstance(save_request, media_module._IdSearchSaveRequest)
|
|
assert save_request.contexts is contexts
|
|
with pytest.raises(StopIteration) as success_completed:
|
|
success.send(None)
|
|
assert success_completed.value.value.contexts is contexts
|
|
|
|
failure = media_module._id_search_resolution(
|
|
recognition_params=recognition_params,
|
|
cache_params=cache_params,
|
|
season=None,
|
|
sites=None,
|
|
area="title",
|
|
cache_local=False,
|
|
failure_keyword="tmdb:100",
|
|
)
|
|
assert isinstance(next(failure), media_module._IdSearchRecognizeRequest)
|
|
with pytest.raises(StopIteration) as failure_completed:
|
|
failure.send(None)
|
|
assert failure_completed.value.value.contexts == []
|
|
assert failure_completed.value.value.warning == "tmdb:100 媒体信息识别失败!"
|
|
|
|
|
|
def test_title_search_resolution_owns_filter_short_circuit_and_save_order():
|
|
"""
|
|
标题状态机应在过滤失败时短路,并仅保存成功投影。
|
|
"""
|
|
search_params = {"keyword": "Movie", "sites": [1], "page": 0}
|
|
cache_params = {"keyword": "Movie", "area": "title"}
|
|
torrent = _torrent("Movie 2026")
|
|
contexts = [SimpleNamespace(name="context")]
|
|
|
|
success = title_module._title_search_resolution(
|
|
title="Movie",
|
|
search_params=search_params,
|
|
cache_params=cache_params,
|
|
cache_local=True,
|
|
mtype=MediaType.MOVIE,
|
|
rule_groups=["quality"],
|
|
)
|
|
assert isinstance(next(success), title_module._TitleSearchCacheRequest)
|
|
assert isinstance(success.send(None), title_module._TitleSearchProviderRequest)
|
|
resolve_request = success.send([torrent])
|
|
assert isinstance(resolve_request, title_module._TitleSearchResolveRequest)
|
|
save_request = success.send(
|
|
title_module._TitleSearchResult(contexts=contexts)
|
|
)
|
|
assert isinstance(save_request, title_module._TitleSearchSaveRequest)
|
|
assert save_request.contexts is contexts
|
|
with pytest.raises(StopIteration) as success_completed:
|
|
success.send(None)
|
|
assert success_completed.value.value.contexts is contexts
|
|
|
|
failure = title_module._title_search_resolution(
|
|
title="Movie",
|
|
search_params=search_params,
|
|
cache_params=cache_params,
|
|
cache_local=True,
|
|
mtype=MediaType.MOVIE,
|
|
rule_groups=["quality"],
|
|
)
|
|
next(failure)
|
|
failure.send(None)
|
|
failure.send([torrent])
|
|
with pytest.raises(StopIteration) as completed:
|
|
failure.send(
|
|
title_module._TitleSearchResult(
|
|
contexts=[],
|
|
warning="Movie 没有符合过滤规则的资源",
|
|
)
|
|
)
|
|
assert completed.value.value.contexts == []
|