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

349 lines
12 KiB
Python
Raw Permalink 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.
"""
统一媒体信息的外部来源投影合同。
"""
from copy import deepcopy
from dataclasses import fields
from types import MappingProxyType
import pytest
from app.domain.context import MediaInfo
from app.domain.projection import anilist as anilist_projection
from app.domain.projection import bangumi as bangumi_projection
from app.domain.projection import douban as douban_projection
from app.domain.projection import tmdb as tmdb_projection
from app.schemas.context import MediaInfo as MediaInfoSchema
from app.schemas.types import MediaSource, MediaType
def test_tmdb_projection_is_complete_and_does_not_mutate_source(monkeypatch) -> None:
"""
TMDB 电视剧详情应完整投影,且保留调用方原始 episode_groups。
"""
source = {
"id": 100,
"media_type": "tv",
"name": "示例剧",
"original_name": "Example Show",
"first_air_date": "2024-01-02",
"poster_path": "/poster.jpg",
"backdrop_path": "/backdrop.jpg",
"vote_average": 8.26,
"external_ids": {"tvdb_id": 200, "imdb_id": "tt100"},
"seasons": [
{"season_number": 0, "episode_count": 2, "air_date": "2023-12-01"},
{"season_number": 1, "episode_count": 3, "air_date": "2024-01-02"},
],
"episode_groups": {"results": [{"id": "group-1"}]},
"credits": {
"cast": [
{"id": 1, "name": "演员", "known_for_department": "Acting"},
{"id": 2, "name": "制片", "known_for_department": "Production"},
],
"crew": [
{"id": 3, "name": "导演", "job": "Director"},
{"id": 4, "name": "灯光", "job": "Lighting"},
],
},
"names": ["示例别名"],
}
original = deepcopy(source)
monkeypatch.setattr(
tmdb_projection,
"_image_url_builder",
lambda path: f"https://image.example{path}",
)
media = MediaInfo(tmdb_info=source)
assert source == original
assert media.tmdb_info == original
assert media.media_source == MediaSource.TMDB
assert media.media_id == "100"
assert media.type == MediaType.TV
assert media.title == "示例剧"
assert media.original_title == "Example Show"
assert media.release_date == "2024-01-02"
assert media.year == "2024"
assert media.tvdb_id == 200
assert media.imdb_id == "tt100"
assert media.vote_average == 8.3
assert media.seasons == {0: [1, 2], 1: [1, 2, 3]}
assert media.season_years == {0: "2023", 1: "2024"}
assert media.episode_groups == [{"id": "group-1"}]
assert media.poster_path == "https://image.example/poster.jpg"
assert media.backdrop_path == "https://image.example/backdrop.jpg"
assert media.actors == [
{"id": 1, "name": "演员", "known_for_department": "Acting"}
]
assert media.directors == [{"id": 3, "name": "导演", "job": "Director"}]
assert media.names == ["示例别名"]
def test_tmdb_empty_seasons_preserve_existing_season_projection() -> None:
"""
TMDB 空季列表应保持历史 skip 语义,不清空已有季集字段。
"""
season_info = [{"season_number": 1, "episode_count": 2}]
media = MediaInfo(
season_info=season_info,
seasons={1: [1, 2]},
season_years={1: "2024"},
tmdb_info={
"id": 101,
"media_type": "tv",
"name": "示例剧",
"seasons": [],
},
)
assert media.season_info == season_info
assert media.seasons == {1: [1, 2]}
assert media.season_years == {1: "2024"}
def test_douban_projection_is_complete_and_does_not_mutate_source() -> None:
"""
豆瓣详情应统一处理季、年份、海报、别名、时长和国家字段。
"""
source = {
"id": "1292052",
"title": "示例剧 第二季",
"original_title": "Example Show",
"subtype": "tv",
"year": "2025",
"rating": {"value": "8.8"},
"pubdate": ["中国大陆 2025-02-03"],
"cover_url": "https://img.example/imageView2/0/q/80/w/9999/h/120/format/webp",
"intro": "示例简介",
"directors": [{"name": "导演"}],
"actors": [{"name": "演员"}],
"aka": ["示例别名(港台译名)"],
"episodes_count": 4,
"season_years": {1: None, 2: 2025},
"genres": ["剧情"],
"durations": ["45分钟"],
"countries": ["中国大陆"],
"languages": ["汉语普通话"],
}
original = deepcopy(source)
media = MediaInfo(douban_info=source)
assert source == original
assert media.douban_info == original
assert media.media_source == MediaSource.Douban
assert media.media_id == "1292052"
assert media.type == MediaType.TV
assert media.title == "示例剧 第二季"
assert media.season == 2
assert media.year == "2025"
assert media.vote_average == 8.8
assert media.release_date == "2025-02-03"
assert media.poster_path == "https://img.example/imageView2/1/w/500/h/750/format/webp"
assert media.names == ["示例别名"]
assert media.seasons == {2: [1, 2, 3, 4]}
assert media.season_years == {2: "2025"}
assert media.genres == [{"id": "剧情", "name": "剧情"}]
assert media.runtime == 45
assert media.production_countries == [{"id": "中国大陆", "name": "中国大陆"}]
assert media.original_language == "zh"
@pytest.mark.parametrize(
("language", "expected"),
[("汉语普通话", "zh"), ("粤语", "cn"), ("英语", "en"), ("ja", "ja")],
)
def test_douban_projection_maps_language_names_to_codes(language: str, expected: str) -> None:
"""
豆瓣常见语言名称应转换为稳定的分类语言代码。
"""
media = MediaInfo(
douban_info={
"id": "1292052",
"title": "示例电影",
"type": "movie",
"languages": [language],
}
)
assert media.original_language == expected
def test_bangumi_projection_is_complete_and_does_not_mutate_source() -> None:
"""
Bangumi 详情应统一处理剧场版、别名、制作和演职员字段。
"""
source = {
"id": 300,
"name": "Example Movie",
"name_cn": "示例电影",
"platform": "剧场版",
"date": "2026-01-02",
"rating": {"score": "9.1"},
"images": {"large": "https://img.example/bangumi.jpg"},
"summary": "Bangumi 简介",
"tags": [{"name": "动画"}],
"infobox": [
{"key": "别名", "value": [{"v": "别名一"}, "别名二"]},
{"key": "动画制作", "value": [{"v": "Studio A"}]},
{"key": "导演", "value": "导演甲"},
],
"actors": [{"name": "演员甲"}],
}
original = deepcopy(source)
media = MediaInfo(bangumi_info=source)
assert source == original
assert media.bangumi_info == original
assert media.media_source == MediaSource.Bangumi
assert media.media_id == "300"
assert media.type == MediaType.MOVIE
assert media.title == "示例电影"
assert media.original_title == "Example Movie"
assert media.year == "2026"
assert media.vote_average == 9.1
assert media.poster_path == "https://img.example/bangumi.jpg"
assert media.names == ["别名一", "别名二"]
assert media.genres == [{"id": "动画", "name": "动画"}]
assert media.production_companies == [{"name": "Studio A"}]
assert media.directors == [{"name": "导演甲"}]
assert media.actors == [{"name": "演员甲"}]
def test_anilist_projection_is_complete_and_does_not_mutate_source() -> None:
"""
AniList 详情应统一处理中文标题、模糊日期、季集和 AniDB 身份。
"""
source = {
"id": 400,
"title": {
"chinese": "葬送的芙莉莲",
"native": "葬送のフリーレン",
"romaji": "Sousou no Frieren",
"english": "Frieren",
},
"format": "TV",
"startDate": {"year": 2023, "month": 9, "day": 29},
"endDate": {"year": 2024, "month": 3},
"episodes": 3,
"duration": 24,
"averageScore": 91,
"countryOfOrigin": "JP",
"coverImage": {"extraLarge": "https://img.example/anilist.jpg"},
"bannerImage": "https://img.example/banner.jpg",
"description": "A <b>journey</b><br>afterwards",
"genres": ["Fantasy"],
"studios": {"nodes": [{"name": "Madhouse"}]},
"actors": [{"name": "演员"}],
"directors": [{"name": "导演"}],
"externalLinks": [{"site": "AniDB", "url": "https://anidb.net/anime/17617"}],
}
original = deepcopy(source)
media = MediaInfo(anilist_info=source)
assert source == original
assert media.anilist_info == original
assert media.media_source == MediaSource.AniList
assert media.media_id == "400"
assert media.type == MediaType.TV
assert media.title == "葬送的芙莉莲"
assert media.en_title == "Frieren"
assert media.original_title == "葬送のフリーレン"
assert media.release_date == "2023-09-29"
assert media.last_air_date == "2024-03"
assert media.year == "2023"
assert media.poster_path == "https://img.example/anilist.jpg"
assert media.backdrop_path == "https://img.example/banner.jpg"
assert media.overview == "A journey\nafterwards"
assert media.vote_average == 9.1
assert media.original_language == "ja"
assert media.origin_country == ["JP"]
assert media.production_companies == [{"name": "Madhouse"}]
assert media.genres == [{"id": "Fantasy", "name": "Fantasy"}]
assert media.seasons == {1: [1, 2, 3]}
assert media.season_years == {1: "2023"}
assert media.anidb_id == 17617
def test_multiple_source_projection_keeps_constructor_order_and_identity() -> None:
"""
多来源详情同时存在时保持 TMDB 到 AniList 的历史应用顺序。
"""
media = MediaInfo(
tmdb_info={"id": 1, "media_type": "movie", "title": "TMDB 标题"},
douban_info={"id": "2", "title": "豆瓣标题", "type": "movie"},
bangumi_info={"id": 3, "name_cn": "Bangumi 标题", "platform": "TV"},
anilist_info={"id": 4, "title": {"chinese": "AniList 标题"}, "format": "TV"},
)
assert media.media_source == MediaSource.AniList
assert media.media_id == "4"
assert media.type == MediaType.MOVIE
assert media.title == "TMDB 标题"
assert media.tmdb_info["id"] == 1
assert media.douban_info["id"] == "2"
assert media.bangumi_info["id"] == 3
assert media.anilist_info["id"] == 4
@pytest.mark.parametrize(
("projector", "source"),
[
(tmdb_projection.project, {"id": 1, "media_type": "movie", "title": "TMDB"}),
(douban_projection.project, {"id": 2, "title": "豆瓣", "type": "movie"}),
(bangumi_projection.project, {"id": 3, "name_cn": "Bangumi", "platform": "TV"}),
(anilist_projection.project, {"id": 4, "title": {"chinese": "AniList"}, "format": "TV"}),
],
)
def test_projection_accepts_read_only_mappings_without_mutating_current_snapshot(
projector, source: dict
) -> None:
"""
四个 owner 都应接受只读 Mapping,并保持当前快照和来源输入不变。
"""
current = vars(MediaInfo())
current_before = deepcopy(current)
source_before = deepcopy(source)
projected = projector(MappingProxyType(current), MappingProxyType(source))
assert isinstance(projected, dict)
assert current == current_before
assert source == source_before
def test_media_schema_is_transport_projection_not_duplicate_domain_logic() -> None:
"""
API DTO 只比领域数据字段多两个派生输出字段。
"""
domain_fields = {item.name for item in fields(MediaInfo)}
schema_fields = set(MediaInfoSchema.model_fields)
assert schema_fields - domain_fields == {"detail_link", "title_year"}
assert domain_fields - schema_fields == set()
@pytest.mark.parametrize(
("method_name", "expected_parameters"),
[
("set_tmdb_info", ("self", "info")),
("set_douban_info", ("self", "info")),
("set_bangumi_info", ("self", "info")),
("set_anilist_info", ("self", "info")),
],
)
def test_media_source_setter_abi_is_stable(
method_name: str, expected_parameters: tuple[str, ...]
) -> None:
"""
四个历史 setter 继续由 canonical MediaInfo 以原参数名提供。
"""
import inspect
method = getattr(MediaInfo, method_name)
assert tuple(inspect.signature(method).parameters) == expected_parameters