"""
统一媒体信息的外部来源投影合同。
"""
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 journey
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