mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-09 23:44:20 +08:00
187 lines
6.5 KiB
Python
187 lines
6.5 KiB
Python
import asyncio
|
|
from unittest.mock import AsyncMock, Mock
|
|
from xml.dom import minidom
|
|
|
|
import pytest
|
|
|
|
from app.core.context import MediaInfo
|
|
from app.core.meta import MetaBase
|
|
from app.helper.scraper import MediaScraperHelper
|
|
from app.modules.anilist import AniListModule
|
|
from app.modules.anilist.anilist import AniListApi
|
|
from app.schemas.types import MediaType
|
|
|
|
|
|
@pytest.fixture
|
|
def anilist_info() -> dict:
|
|
"""构造不依赖网络的AniList媒体详情。"""
|
|
return {
|
|
"id": 154587,
|
|
"title": {
|
|
"romaji": "Sousou no Frieren",
|
|
"english": "Frieren: Beyond Journey's End",
|
|
"native": "葬送のフリーレン",
|
|
"chinese": "葬送的芙莉莲",
|
|
},
|
|
"format": "TV",
|
|
"status": "FINISHED",
|
|
"description": "A <b>journey</b> after the adventure.",
|
|
"startDate": {"year": 2023, "month": 9, "day": 29},
|
|
"endDate": {"year": 2024, "month": 3, "day": 22},
|
|
"episodes": 28,
|
|
"duration": 24,
|
|
"countryOfOrigin": "JP",
|
|
"coverImage": {"extraLarge": "https://img.example/poster.jpg"},
|
|
"bannerImage": "https://img.example/backdrop.png",
|
|
"genres": ["Adventure", "Fantasy"],
|
|
"synonyms": ["葬送的芙莉莲", "Frieren"],
|
|
"averageScore": 91,
|
|
"popularity": 300000,
|
|
"isAdult": False,
|
|
"studios": {"nodes": [{"name": "Madhouse"}]},
|
|
"staff": {
|
|
"edges": [
|
|
{
|
|
"role": "Director",
|
|
"node": {
|
|
"name": {"full": "Keiichiro Saito"},
|
|
"image": {"large": "https://img.example/director.jpg"},
|
|
"siteUrl": "https://anilist.co/staff/1",
|
|
},
|
|
}
|
|
]
|
|
},
|
|
"characters": {
|
|
"edges": [
|
|
{
|
|
"node": {"name": {"full": "Frieren"}},
|
|
"voiceActors": [
|
|
{
|
|
"name": {"full": "Atsumi Tanezaki"},
|
|
"image": {"large": "https://img.example/actor.jpg"},
|
|
"siteUrl": "https://anilist.co/staff/2",
|
|
}
|
|
],
|
|
}
|
|
]
|
|
},
|
|
"externalLinks": [
|
|
{"site": "AniDB", "url": "https://anidb.net/anime/17617"}
|
|
],
|
|
}
|
|
|
|
|
|
def test_anilist_id_recognition_normalizes_media_info(anilist_info: dict) -> None:
|
|
"""AniList ID识别应生成可供整理和刮削复用的统一媒体信息。"""
|
|
module = AniListModule()
|
|
module.anilist_api = Mock()
|
|
module.anilist_api.detail.return_value = anilist_info
|
|
|
|
media = module.recognize_media(anilistid=154587)
|
|
|
|
assert media is not None
|
|
assert media.source == "anilist"
|
|
assert media.anilist_id == 154587
|
|
assert media.title == "葬送的芙莉莲"
|
|
assert media.anidb_id == 17617
|
|
assert media.type == MediaType.TV
|
|
assert media.year == "2023"
|
|
assert media.number_of_episodes == 28
|
|
assert media.seasons[1] == list(range(1, 29))
|
|
assert media.genres == [
|
|
{"id": "Adventure", "name": "Adventure"},
|
|
{"id": "Fantasy", "name": "Fantasy"},
|
|
]
|
|
assert media.production_companies == [{"name": "Madhouse"}]
|
|
assert media.directors[0]["name"] == "Keiichiro Saito"
|
|
assert media.actors[0]["character"] == "Frieren"
|
|
module.anilist_api.detail.assert_called_once_with(154587)
|
|
|
|
|
|
def test_anilist_title_recognition_respects_request_source(anilist_info: dict) -> None:
|
|
"""标题识别仅在本次请求明确选择AniList时使用AniList候选项。"""
|
|
module = AniListModule()
|
|
module.anilist_api = Mock()
|
|
module.anilist_api.search.return_value = [anilist_info]
|
|
meta = MetaBase("Frieren")
|
|
meta.cn_name = "Frieren"
|
|
meta.type = MediaType.TV
|
|
meta.year = "2023"
|
|
|
|
media = module.recognize_media(meta=meta, source="anilist")
|
|
skipped = module.recognize_media(meta=meta, source="douban")
|
|
|
|
assert media is not None
|
|
assert media.anilist_id == 154587
|
|
assert skipped is None
|
|
module.anilist_api.search.assert_called_once_with("Frieren")
|
|
|
|
|
|
def test_async_anilist_title_recognition(anilist_info: dict) -> None:
|
|
"""异步AniList标题识别应与同步结果保持一致。"""
|
|
module = AniListModule()
|
|
module.anilist_api = Mock()
|
|
module.anilist_api.async_search = AsyncMock(return_value=[anilist_info])
|
|
meta = MetaBase("Frieren")
|
|
meta.cn_name = "Frieren"
|
|
meta.type = MediaType.TV
|
|
|
|
media = asyncio.run(
|
|
module.async_recognize_media(meta=meta, source="anilist")
|
|
)
|
|
|
|
assert media is not None
|
|
assert media.anilist_id == 154587
|
|
module.anilist_api.async_search.assert_awaited_once_with("Frieren")
|
|
|
|
|
|
def test_anilist_scraper_generates_nfo_and_images(anilist_info: dict) -> None:
|
|
"""AniList媒体信息应生成带来源ID的NFO以及主海报和背景图。"""
|
|
module = AniListModule()
|
|
module.scraper = MediaScraperHelper()
|
|
media = MediaInfo(anilist_info=anilist_info)
|
|
media.scrape_source = "anilist"
|
|
|
|
nfo = module.metadata_nfo(media)
|
|
images = module.metadata_img(media)
|
|
document = minidom.parseString(nfo)
|
|
unique_id = document.getElementsByTagName("uniqueid")[0]
|
|
|
|
assert document.documentElement.tagName == "tvshow"
|
|
assert unique_id.firstChild.data == "154587"
|
|
assert unique_id.getAttribute("type") == "anilist"
|
|
assert images == {
|
|
"poster.jpg": "https://img.example/poster.jpg",
|
|
"backdrop.png": "https://img.example/backdrop.png",
|
|
}
|
|
|
|
|
|
def test_anilist_api_extracts_graphql_errors_without_network() -> None:
|
|
"""AniList客户端应把GraphQL错误响应统一视为无结果。"""
|
|
response = Mock(status_code=200)
|
|
response.json.return_value = {"errors": [{"message": "invalid"}]}
|
|
|
|
assert AniListApi._extract_response(response) is None
|
|
|
|
|
|
def test_anilist_title_falls_back_to_native_language(anilist_info: dict) -> None:
|
|
"""anilist-chinese 未注入中文标题时应优先回退原语言标题。"""
|
|
anilist_info["synonyms"] = ["Frieren"]
|
|
anilist_info["title"].pop("chinese")
|
|
|
|
media = MediaInfo(anilist_info=anilist_info)
|
|
|
|
assert media.title == "葬送のフリーレン"
|
|
|
|
|
|
def test_anilist_title_uses_injected_chinese_synonym_for_latin_title(
|
|
anilist_info: dict,
|
|
) -> None:
|
|
"""代理主标题仍为拉丁字母时应选择其追加的中文别名。"""
|
|
anilist_info["title"]["chinese"] = "Frieren"
|
|
anilist_info["synonyms"] = ["Official Alias", "葬送的芙莉莲"]
|
|
|
|
media = MediaInfo(anilist_info=anilist_info)
|
|
|
|
assert media.title == "葬送的芙莉莲"
|