mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/jxxghp/MoviePilot.git
synced 2026-09-20 08:03:34 +08:00
refactor(tmdb): unify api sync async decisions
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -78,8 +78,8 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
|
||||
| Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement |
|
||||
| Python 源码量 | 约 298,700 行 | 60 个文件超过 1,000 行,10 个超过 2,000 行 |
|
||||
| 长方法 | 299 个超过 80 行 | 70 个超过 150 行,23 个超过 250 行;大量是私有方法 |
|
||||
| 全量 mypy 历史债务 | 10,190 / 592 文件 | 识别链 typed plan/result、TMDB、消息、Indexer、目录源与 Recommend 双 ABI 共用计划继续清除参数、返回值和泛型注解债;strict frontier 当前覆盖 41 个文件 |
|
||||
| Ruff 历史诊断 | 641 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
|
||||
| 全量 mypy 历史债务 | 10,100 / 592 文件 | 识别链 typed plan/result、TMDB API、消息、Indexer、目录源与 Recommend 双 ABI 共用计划继续清除参数、返回值和泛型注解债;strict frontier 当前覆盖 41 个文件 |
|
||||
| Ruff 历史诊断 | 640 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
|
||||
| 覆盖率低水位 | Application 81.00%,Domain 80.67% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
|
||||
|
||||
### 3.3 热点文件
|
||||
|
||||
17
tests/fixtures/architecture/mypy-baseline.json
vendored
17
tests/fixtures/architecture/mypy-baseline.json
vendored
@@ -2569,7 +2569,7 @@
|
||||
"override": 1
|
||||
},
|
||||
"app/modules/themoviedb/__init__.py": {
|
||||
"arg-type": 15,
|
||||
"arg-type": 9,
|
||||
"assignment": 12,
|
||||
"empty-body": 1,
|
||||
"index": 2,
|
||||
@@ -2603,17 +2603,14 @@
|
||||
"union-attr": 5
|
||||
},
|
||||
"app/modules/themoviedb/tmdbapi.py": {
|
||||
"arg-type": 13,
|
||||
"assignment": 6,
|
||||
"arg-type": 1,
|
||||
"attr-defined": 11,
|
||||
"no-any-return": 24,
|
||||
"no-untyped-call": 74,
|
||||
"no-untyped-def": 11,
|
||||
"no-any-return": 5,
|
||||
"no-untyped-call": 70,
|
||||
"no-untyped-def": 7,
|
||||
"operator": 24,
|
||||
"return-value": 2,
|
||||
"type-arg": 80,
|
||||
"union-attr": 4,
|
||||
"var-annotated": 2
|
||||
"type-arg": 47,
|
||||
"union-attr": 2
|
||||
},
|
||||
"app/modules/themoviedb/tmdbv3api/as_obj.py": {
|
||||
"assignment": 1,
|
||||
|
||||
@@ -580,9 +580,6 @@
|
||||
"app/modules/themoviedb/tmdb_cache.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/modules/themoviedb/tmdbapi.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/modules/themoviedb/tmdbv3api/__init__.py": {
|
||||
"F401": 25
|
||||
},
|
||||
|
||||
368
tests/test_tmdbapi_sync_async_parity.py
Normal file
368
tests/test_tmdbapi_sync_async_parity.py
Normal file
@@ -0,0 +1,368 @@
|
||||
"""TmdbApi 同步与异步入口共享业务决策的回归测试。"""
|
||||
|
||||
import asyncio
|
||||
from copy import deepcopy
|
||||
from types import SimpleNamespace
|
||||
from typing import Optional, Union
|
||||
from unittest.mock import AsyncMock, Mock, call
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.themoviedb.tmdbapi import TmdbApi
|
||||
from app.modules.themoviedb.tmdbv3api.exceptions import (
|
||||
TMDbConnectionError,
|
||||
TMDbException,
|
||||
)
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
|
||||
def _api() -> TmdbApi:
|
||||
"""构造不初始化真实 TMDB 客户端的最小 API 实例。"""
|
||||
api = object.__new__(TmdbApi)
|
||||
api.tmdb = SimpleNamespace(language="en")
|
||||
api.search = SimpleNamespace(
|
||||
total_results=0,
|
||||
movies=Mock(),
|
||||
async_movies=AsyncMock(),
|
||||
tv_shows=Mock(),
|
||||
async_tv_shows=AsyncMock(),
|
||||
multi=Mock(),
|
||||
async_multi=AsyncMock(),
|
||||
collections=Mock(),
|
||||
async_collections=AsyncMock(),
|
||||
)
|
||||
api.movie = SimpleNamespace(details=Mock(), async_details=AsyncMock())
|
||||
api.tv = SimpleNamespace(details=Mock(), async_details=AsyncMock())
|
||||
return api
|
||||
|
||||
|
||||
def _movie_detail(tmdbid: int, title: str = "展示标题") -> dict:
|
||||
"""构造包含别名、分级和多语言标题的电影详情。"""
|
||||
return {
|
||||
"id": tmdbid,
|
||||
"title": title,
|
||||
"original_title": "Original Title",
|
||||
"original_language": "en",
|
||||
"release_date": "2024-06-01",
|
||||
"genres": [{"id": 18}, {"id": 35}],
|
||||
"alternative_titles": {"titles": [{"iso_3166_1": "US", "title": "目标别名"}]},
|
||||
"translations": {
|
||||
"translations": [
|
||||
{
|
||||
"iso_3166_1": "HK",
|
||||
"data": {"title": "香港标题"},
|
||||
}
|
||||
]
|
||||
},
|
||||
"release_dates": {
|
||||
"results": [
|
||||
{
|
||||
"iso_3166_1": "US",
|
||||
"release_dates": [{"certification": "PG-13"}],
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _tv_detail(tmdbid: int, name: str = "目标剧") -> dict:
|
||||
"""构造包含季年份和译名的电视剧详情。"""
|
||||
return {
|
||||
"id": tmdbid,
|
||||
"name": name,
|
||||
"original_name": "Original Series",
|
||||
"original_language": "en",
|
||||
"first_air_date": "2018-01-01",
|
||||
"genres": [{"id": 18}],
|
||||
"seasons": [{"season_number": 3, "air_date": "2024-03-02"}],
|
||||
"alternative_titles": {"results": [{"iso_3166_1": "US", "title": "剧集别名"}]},
|
||||
"translations": {"translations": []},
|
||||
"content_ratings": {"results": []},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mtype", "sync_name", "async_name", "title_field", "year_kwargs"),
|
||||
[
|
||||
(
|
||||
MediaType.MOVIE,
|
||||
"search_movies",
|
||||
"async_search_movies",
|
||||
"title",
|
||||
{"term": "目标", "year": "2024"},
|
||||
),
|
||||
(
|
||||
MediaType.TV,
|
||||
"search_tvs",
|
||||
"async_search_tvs",
|
||||
"name",
|
||||
{"term": "目标", "release_year": "2024"},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_search_lists_share_query_and_projection(
|
||||
mtype: MediaType,
|
||||
sync_name: str,
|
||||
async_name: str,
|
||||
title_field: str,
|
||||
year_kwargs: dict[str, str],
|
||||
) -> None:
|
||||
"""电影和剧集列表双入口应共享参数、标题过滤和类型投影。"""
|
||||
api = _api()
|
||||
sync_items = [
|
||||
{"id": 1, title_field: "目标作品"},
|
||||
{"id": 2, title_field: "其他作品"},
|
||||
]
|
||||
async_items = deepcopy(sync_items)
|
||||
sync_client = api.search.movies if mtype == MediaType.MOVIE else api.search.tv_shows
|
||||
async_client = api.search.async_movies if mtype == MediaType.MOVIE else api.search.async_tv_shows
|
||||
sync_client.return_value = sync_items
|
||||
async_client.return_value = async_items
|
||||
|
||||
sync_result = getattr(api, sync_name)("目标", "2024")
|
||||
async_result = asyncio.run(getattr(api, async_name)("目标", "2024"))
|
||||
|
||||
assert sync_result == async_result == [{"id": 1, title_field: "目标作品", "media_type": mtype}]
|
||||
sync_client.assert_called_once_with(**year_kwargs)
|
||||
async_client.assert_awaited_once_with(**year_kwargs)
|
||||
|
||||
|
||||
def test_match_movie_sync_async_share_year_fallback_and_original_title() -> None:
|
||||
"""电影匹配双入口应按当前、下一年顺序回退并采信原标题。"""
|
||||
api = _api()
|
||||
sync_hit = {
|
||||
"id": 10,
|
||||
"title": "本地标题",
|
||||
"original_title": "Target Movie",
|
||||
"release_date": "2025-02-01",
|
||||
}
|
||||
async_hit = deepcopy(sync_hit)
|
||||
api.search.movies.side_effect = [[], [sync_hit]]
|
||||
api.search.async_movies.side_effect = [[], [async_hit]]
|
||||
|
||||
sync_result = api.match("Target Movie", MediaType.MOVIE, year="2024")
|
||||
async_result = asyncio.run(api.async_match("Target Movie", MediaType.MOVIE, year="2024"))
|
||||
|
||||
assert sync_result == async_result
|
||||
assert sync_result["media_type"] == MediaType.MOVIE
|
||||
expected_calls = [
|
||||
call(term="Target Movie", year="2024"),
|
||||
call(term="Target Movie", year="2025"),
|
||||
]
|
||||
assert api.search.movies.call_args_list == expected_calls
|
||||
assert api.search.async_movies.call_args_list == expected_calls
|
||||
|
||||
|
||||
def test_match_movie_sync_async_share_alias_detail_fallback() -> None:
|
||||
"""标题未命中时双入口应同序查询详情并使用别名。"""
|
||||
api = _api()
|
||||
sync_candidate = {
|
||||
"id": 11,
|
||||
"title": "展示标题",
|
||||
"original_title": "Original Title",
|
||||
"release_date": "2024-06-01",
|
||||
}
|
||||
async_candidate = deepcopy(sync_candidate)
|
||||
sync_detail = _movie_detail(11)
|
||||
async_detail = deepcopy(sync_detail)
|
||||
api.search.movies.return_value = [sync_candidate]
|
||||
api.search.async_movies.return_value = [async_candidate]
|
||||
api.movie.details.return_value = sync_detail
|
||||
api.movie.async_details.return_value = async_detail
|
||||
|
||||
sync_result = api.match("目标别名", MediaType.MOVIE, year="2024")
|
||||
async_result = asyncio.run(api.async_match("目标别名", MediaType.MOVIE, year="2024"))
|
||||
|
||||
assert sync_result == async_result
|
||||
assert sync_result["id"] == 11
|
||||
assert "目标别名" in sync_result["names"]
|
||||
api.movie.details.assert_called_once()
|
||||
api.movie.async_details.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"group_seasons",
|
||||
[
|
||||
None,
|
||||
[{"order": 3, "episodes": [{"air_date": "2024-04-01"}]}],
|
||||
],
|
||||
)
|
||||
def test_match_tv_season_sync_async_share_detail_year_decision(
|
||||
group_seasons: Optional[list[dict]],
|
||||
) -> None:
|
||||
"""首播年份不同的剧集应由同一季年份规则在双入口命中。"""
|
||||
api = _api()
|
||||
sync_candidate = {
|
||||
"id": 20,
|
||||
"name": "目标剧",
|
||||
"original_name": "Original Series",
|
||||
"first_air_date": "2018-01-01",
|
||||
}
|
||||
async_candidate = deepcopy(sync_candidate)
|
||||
sync_detail = _tv_detail(20)
|
||||
async_detail = deepcopy(sync_detail)
|
||||
api.search.tv_shows.return_value = [sync_candidate]
|
||||
api.search.async_tv_shows.return_value = [async_candidate]
|
||||
api.tv.details.return_value = sync_detail
|
||||
api.tv.async_details.return_value = async_detail
|
||||
|
||||
sync_result = api.match(
|
||||
"目标剧",
|
||||
MediaType.TV,
|
||||
year="2018",
|
||||
season_year="2024",
|
||||
season_number=3,
|
||||
group_seasons=group_seasons,
|
||||
)
|
||||
async_result = asyncio.run(
|
||||
api.async_match(
|
||||
"目标剧",
|
||||
MediaType.TV,
|
||||
year="2018",
|
||||
season_year="2024",
|
||||
season_number=3,
|
||||
group_seasons=group_seasons,
|
||||
)
|
||||
)
|
||||
|
||||
assert sync_result == async_result
|
||||
assert sync_result["id"] == 20
|
||||
assert api.search.tv_shows.call_args_list == [call(term="目标剧")]
|
||||
assert api.search.async_tv_shows.call_args_list == [call(term="目标剧")]
|
||||
api.tv.details.assert_called_once()
|
||||
api.tv.async_details.assert_awaited_once()
|
||||
|
||||
|
||||
def test_match_multi_sync_async_share_sorting_and_alias_type_lookup() -> None:
|
||||
"""多类型别名匹配双入口应跳过无关类型并按候选类型查详情。"""
|
||||
api = _api()
|
||||
sync_multis = [
|
||||
{"id": 1, "media_type": "person", "name": "目标别名"},
|
||||
{
|
||||
"id": 21,
|
||||
"media_type": "tv",
|
||||
"name": "展示剧名",
|
||||
"original_name": "Original Series",
|
||||
"first_air_date": "2024-01-01",
|
||||
},
|
||||
]
|
||||
async_multis = deepcopy(sync_multis)
|
||||
sync_detail = _tv_detail(21, name="展示剧名")
|
||||
sync_detail["alternative_titles"]["results"][0]["title"] = "目标别名"
|
||||
async_detail = deepcopy(sync_detail)
|
||||
api.search.multi.return_value = sync_multis
|
||||
api.search.async_multi.return_value = async_multis
|
||||
api.tv.details.return_value = sync_detail
|
||||
api.tv.async_details.return_value = async_detail
|
||||
|
||||
sync_result = api.match_multi("目标别名")
|
||||
async_result = asyncio.run(api.async_match_multi("目标别名"))
|
||||
|
||||
assert sync_result == async_result
|
||||
assert sync_result["id"] == 21
|
||||
assert sync_result["media_type"] == MediaType.TV
|
||||
api.movie.details.assert_not_called()
|
||||
api.movie.async_details.assert_not_awaited()
|
||||
api.tv.details.assert_called_once()
|
||||
api.tv.async_details.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("outcome", "expected"),
|
||||
[([], {}), (TMDbException("查询失败"), None)],
|
||||
)
|
||||
def test_match_multi_sync_async_share_empty_and_exception_results(
|
||||
outcome: Union[list, Exception], expected: Optional[dict]
|
||||
) -> None:
|
||||
"""多类型搜索的空结果和异常在双入口应保留相同三态语义。"""
|
||||
api = _api()
|
||||
if isinstance(outcome, Exception):
|
||||
api.search.multi.side_effect = outcome
|
||||
api.search.async_multi.side_effect = outcome
|
||||
else:
|
||||
api.search.multi.return_value = outcome
|
||||
api.search.async_multi.return_value = deepcopy(outcome)
|
||||
|
||||
assert api.match_multi("目标") == expected
|
||||
assert asyncio.run(api.async_match_multi("目标")) == expected
|
||||
|
||||
|
||||
def test_get_info_sync_async_share_projection() -> None:
|
||||
"""详情双入口应共享类型、别名、分级和多语言字段投影。"""
|
||||
api = _api()
|
||||
sync_detail = _movie_detail(30)
|
||||
async_detail = deepcopy(sync_detail)
|
||||
api.movie.details.return_value = sync_detail
|
||||
api.movie.async_details.return_value = async_detail
|
||||
|
||||
sync_result = api.get_info(MediaType.MOVIE, 30)
|
||||
async_result = asyncio.run(api.async_get_info(MediaType.MOVIE, 30))
|
||||
|
||||
assert sync_result == async_result
|
||||
assert sync_result["media_type"] == MediaType.MOVIE
|
||||
assert sync_result["genre_ids"] == [18, 35]
|
||||
assert sync_result["content_rating"] == "PG-13"
|
||||
assert sync_result["en_title"] == "Original Title"
|
||||
assert "目标别名" in sync_result["names"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("tv_exists", "movie_exists", "expected_type"),
|
||||
[
|
||||
(True, False, MediaType.TV),
|
||||
(False, True, MediaType.MOVIE),
|
||||
(True, True, None),
|
||||
(False, False, None),
|
||||
],
|
||||
)
|
||||
def test_get_info_unknown_type_sync_async_share_lookup_order(
|
||||
tv_exists: bool,
|
||||
movie_exists: bool,
|
||||
expected_type: Optional[MediaType],
|
||||
) -> None:
|
||||
"""未知类型详情双入口应先查剧集再查电影并统一判断歧义。"""
|
||||
api = _api()
|
||||
sync_calls = []
|
||||
async_calls = []
|
||||
|
||||
def sync_tv(*args, **kwargs):
|
||||
sync_calls.append("tv")
|
||||
return _tv_detail(40) if tv_exists else {}
|
||||
|
||||
def sync_movie(*args, **kwargs):
|
||||
sync_calls.append("movie")
|
||||
return _movie_detail(40) if movie_exists else {}
|
||||
|
||||
async def async_tv(*args, **kwargs):
|
||||
async_calls.append("tv")
|
||||
return _tv_detail(40) if tv_exists else {}
|
||||
|
||||
async def async_movie(*args, **kwargs):
|
||||
async_calls.append("movie")
|
||||
return _movie_detail(40) if movie_exists else {}
|
||||
|
||||
api.tv.details.side_effect = sync_tv
|
||||
api.movie.details.side_effect = sync_movie
|
||||
api.tv.async_details.side_effect = async_tv
|
||||
api.movie.async_details.side_effect = async_movie
|
||||
|
||||
sync_result = api.get_info(None, 40)
|
||||
async_result = asyncio.run(api.async_get_info(None, 40))
|
||||
|
||||
assert sync_calls == async_calls == ["tv", "movie"]
|
||||
assert (sync_result or {}).get("media_type") == expected_type
|
||||
assert (async_result or {}).get("media_type") == expected_type
|
||||
assert bool(sync_result) == bool(async_result)
|
||||
|
||||
|
||||
def test_get_info_sync_async_propagate_connection_error_at_same_boundary() -> None:
|
||||
"""显式连接错误传播开关在同步与异步详情入口应保持一致。"""
|
||||
api = _api()
|
||||
error = TMDbConnectionError("无法连接 TMDB")
|
||||
api.movie.details.side_effect = error
|
||||
api.movie.async_details.side_effect = error
|
||||
|
||||
with pytest.raises(TMDbConnectionError):
|
||||
api.get_info(MediaType.MOVIE, 50, raise_on_connection_error=True)
|
||||
with pytest.raises(TMDbConnectionError):
|
||||
asyncio.run(api.async_get_info(MediaType.MOVIE, 50, raise_on_connection_error=True))
|
||||
Reference in New Issue
Block a user