Files
MoviePilot/tests/test_search_sync_async_parity.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

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 == []