Files
MoviePilot/tests/test_media_classification.py

751 lines
23 KiB
Python

"""多媒体、多数据源分类领域能力的 A1 验收测试。"""
import copy
import gc
import statistics
import time
from collections.abc import Sequence
from typing import Any, Callable
import pytest
from app.application.classification.configuration import (
build_default_classification_policy,
)
from app.domain.classification.evaluator import ClassificationEvaluator
from app.domain.classification.fields import get_standard_classification_fields
from app.domain.classification.validation import ClassificationPolicyValidator
from app.schemas.category import (
ClassificationEvaluation,
ClassificationFacts,
ClassificationFieldDefinition,
ClassificationPolicy,
ClassificationValidationResult,
)
_MISSING = object()
def _category(
category_id: str,
media_type: str,
name: str,
path: list[str],
) -> dict[str, Any]:
return {
"id": category_id,
"media_type": media_type,
"name": name,
"path": path,
"enabled": True,
"labels": [],
}
def _base_policy_payload() -> dict[str, Any]:
return {
"schema_version": 2,
"revision": 1,
"mode": "first_match",
"categories": [
_category("movie.fallback", "电影", "未分类", ["未分类"]),
_category("movie.hit", "电影", "命中", ["命中"]),
_category("movie.first", "电影", "第一分类", ["第一分类"]),
_category("movie.second", "电影", "第二分类", ["第二分类"]),
_category("tv.fallback", "电视剧", "未分类", ["未分类"]),
_category("music.fallback", "音乐", "未分类", ["未分类"]),
],
"rules": [],
"fallbacks": {
"电影": "movie.fallback",
"电视剧": "tv.fallback",
"音乐": "music.fallback",
},
"field_aliases": {},
"updated_at": "2026-09-02T12:00:00+08:00",
}
def _category_rule(
rule_id: str,
when: dict[str, Any],
category_id: str = "movie.hit",
*,
media_types: list[str] | None = None,
sources: list[str] | None = None,
labels: list[str] | None = None,
) -> dict[str, Any]:
return {
"id": rule_id,
"name": rule_id,
"kind": "category",
"enabled": True,
"priority": 100,
"media_types": media_types or ["电影"],
"sources": sources or [],
"when": when,
"target": {
"category_id": category_id,
"labels": labels or [],
},
}
def _label_rule(
rule_id: str,
when: dict[str, Any],
labels: list[str],
) -> dict[str, Any]:
return {
"id": rule_id,
"name": rule_id,
"kind": "label",
"enabled": True,
"priority": 100,
"media_types": ["电影"],
"sources": [],
"when": when,
"target": {"labels": labels},
}
def _policy(*rules: dict[str, Any]) -> ClassificationPolicy:
payload = _base_policy_payload()
payload["rules"] = list(rules)
return ClassificationPolicy.model_validate(payload)
def _set_nested(payload: dict[str, Any], field: str, value: Any) -> None:
parent = payload
parts = field.split(".")
for part in parts[:-1]:
parent = parent.setdefault(part, {})
if value is _MISSING:
parent.pop(parts[-1], None)
else:
parent[parts[-1]] = value
def _facts(
*,
media_type: str = "电影",
media_source: str = "themoviedb",
values: dict[str, Any] | None = None,
) -> ClassificationFacts:
payload: dict[str, Any] = {
"identity": {
"media_source": media_source,
"media_id": "media-1",
},
"media": {
"type": media_type,
"title": "Spirited Away",
"year": 2001,
"language": "ja",
"countries": ["JP"],
"genre_keys": ["animation", "family"],
"genre_names": ["Animation", "Family"],
"adult": False,
"runtime": 125,
"content_rating": "PG",
"companies": ["Studio Ghibli"],
"networks": ["NTV"],
},
"music": {
"entity_type": "album",
"album_type": "Album",
"secondary_types": ["Live"],
"genres": ["Rock"],
"tags": ["j-rock"],
"artists": ["Example Artist"],
"artist_country": "JP",
"release_status": "Official",
},
"extensions": {},
}
for field, value in (values or {}).items():
_set_nested(payload, field, value)
return ClassificationFacts.model_validate(payload)
def _evaluate(
policy: ClassificationPolicy,
facts: ClassificationFacts | None = None,
*,
trace: bool = False,
) -> ClassificationEvaluation:
return ClassificationEvaluator().evaluate(
policy=policy,
facts=facts or _facts(),
trace=trace,
)
def _leaf(field: str, operator: str, value: Any = _MISSING) -> dict[str, Any]:
condition = {"field": field, "operator": operator}
if value is not _MISSING:
condition["value"] = value
return condition
@pytest.mark.parametrize( # type: ignore[misc]
("album_type", "secondary_types", "category_id", "category_path"),
[
("Album", ["Compilation"], "music.compilation", ["Album", "Compilation"]),
("EP", [], "music.ep", ["EP"]),
("Single", [], "music.single", ["Single"]),
("Album", [], "music.album", ["Album"]),
],
)
def test_default_music_policy_uses_structured_album_categories(
album_type: str,
secondary_types: list[str],
category_id: str,
category_path: list[str],
) -> None:
"""默认音乐规则应优先识别精选集,并生成不带空白的安全路径段。"""
policy = build_default_classification_policy().model_copy(update={"revision": 1})
result = _evaluate(
policy,
_facts(
media_type="音乐",
media_source="musicbrainz",
values={
"music.album_type": album_type,
"music.secondary_types": secondary_types,
},
),
)
assert result.result.effective is not None
assert result.result.effective.category_id == category_id
assert result.result.effective.category_path == category_path
@pytest.mark.parametrize( # type: ignore[misc]
("condition", "fact_values"),
[
(_leaf("media.language", "equals", "ja"), {}),
(_leaf("media.language", "not_equals", "en"), {}),
(_leaf("media.language", "in", ["ja", "zh"]), {}),
(_leaf("media.language", "not_in", ["en", "fr"]), {}),
(_leaf("media.language", "contains", "a"), {}),
(_leaf("media.language", "starts_with", "j"), {}),
(_leaf("media.language", "ends_with", "a"), {}),
(_leaf("media.runtime", "gt", 120), {}),
(_leaf("media.runtime", "gte", 125), {}),
(_leaf("media.runtime", "lt", 130), {}),
(_leaf("media.runtime", "lte", 125), {}),
(_leaf("media.runtime", "between", [120, 130]), {}),
(_leaf("media.genre_keys", "contains_any", ["animation", "music"]), {}),
(_leaf("media.genre_keys", "contains_all", ["animation", "family"]), {}),
(_leaf("media.genre_keys", "contains_none", ["horror", "war"]), {}),
(_leaf("media.adult", "is_true"), {"media.adult": True}),
(_leaf("media.adult", "is_false"), {}),
(_leaf("media.content_rating", "exists"), {}),
(_leaf("media.content_rating", "not_exists"), {"media.content_rating": _MISSING}),
],
ids=[
"equals",
"not-equals",
"in",
"not-in",
"contains",
"starts-with",
"ends-with",
"gt",
"gte",
"lt",
"lte",
"between",
"contains-any",
"contains-all",
"contains-none",
"is-true",
"is-false",
"exists",
"not-exists",
],
)
def test_all_operators_have_a_representative_match(
condition: dict[str, Any],
fact_values: dict[str, Any],
) -> None:
evaluation = _evaluate(
_policy(_category_rule("rule.operator", condition)),
_facts(values=fact_values),
)
assert evaluation.result.recommended.category_id == "movie.hit"
assert evaluation.result.recommended.rule_id == "rule.operator"
@pytest.mark.parametrize("missing_value", [_MISSING, None], ids=["missing", "null"]) # type: ignore[misc]
@pytest.mark.parametrize( # type: ignore[misc]
("field", "operator", "expected"),
[
("media.content_rating", "not_equals", "R"),
("media.content_rating", "not_in", ["R", "NC-17"]),
("media.networks", "contains_none", ["Netflix"]),
],
)
def test_negative_operators_do_not_match_missing_or_null_values(
missing_value: Any,
field: str,
operator: str,
expected: Any,
) -> None:
evaluation = _evaluate(
_policy(_category_rule("rule.negative", _leaf(field, operator, expected))),
_facts(values={field: missing_value}),
)
assert evaluation.result.recommended.category_id == "movie.fallback"
assert evaluation.result.recommended.rule_id is None
@pytest.mark.parametrize("missing_value", [_MISSING, None], ids=["missing", "null"]) # type: ignore[misc]
def test_not_exists_matches_missing_and_null_values(missing_value: Any) -> None:
evaluation = _evaluate(
_policy(
_category_rule(
"rule.not-exists",
_leaf("media.content_rating", "not_exists"),
)
),
_facts(values={"media.content_rating": missing_value}),
)
assert evaluation.result.recommended.category_id == "movie.hit"
def test_all_any_and_not_groups_can_be_nested() -> None:
condition = {
"all": [
_leaf("media.year", "gte", 2000),
{
"any": [
_leaf("media.language", "equals", "zh"),
_leaf("media.countries", "contains_any", ["JP"]),
]
},
{"not": _leaf("media.adult", "is_true")},
]
}
evaluation = _evaluate(_policy(_category_rule("rule.nested", condition)))
assert evaluation.result.recommended.category_id == "movie.hit"
assert evaluation.result.recommended.rule_id == "rule.nested"
def test_first_matching_category_rule_wins() -> None:
always = _leaf("media.type", "equals", "电影")
evaluation = _evaluate(
_policy(
_category_rule("rule.first", always, "movie.first"),
_category_rule("rule.second", always, "movie.second"),
)
)
assert evaluation.result.recommended.category_id == "movie.first"
assert evaluation.result.recommended.rule_id == "rule.first"
assert evaluation.result.effective == evaluation.result.recommended
def test_matching_label_rules_accumulate_with_stable_deduplication() -> None:
always = _leaf("media.type", "equals", "电影")
evaluation = _evaluate(
_policy(
_category_rule(
"rule.category",
always,
labels=["base", "shared"],
),
_label_rule("rule.label-one", always, ["alpha", "shared"]),
_label_rule("rule.label-two", always, ["alpha", "omega"]),
)
)
assert evaluation.result.labels == ["base", "shared", "alpha", "omega"]
def test_media_type_fallback_is_used_when_no_category_rule_matches() -> None:
evaluation = _evaluate(
_policy(
_category_rule(
"rule.no-match",
_leaf("media.year", "lt", 1900),
)
)
)
assert evaluation.result.recommended.category_id == "movie.fallback"
assert evaluation.result.recommended.category_path == ["未分类"]
assert evaluation.result.recommended.rule_id is None
assert evaluation.result.effective.source == "fallback"
@pytest.mark.parametrize( # type: ignore[misc]
("media_type", "media_source", "expected_category"),
[
("电影", "themoviedb", "movie.hit"),
("电影", "douban", "movie.fallback"),
("音乐", "themoviedb", "music.fallback"),
],
)
def test_rule_source_and_media_type_restrictions_are_applied_before_conditions(
media_type: str,
media_source: str,
expected_category: str,
) -> None:
rule = _category_rule(
"rule.restricted",
_leaf("media.year", "gte", 2000),
media_types=["电影"],
sources=["themoviedb"],
)
evaluation = _evaluate(
_policy(rule),
_facts(media_type=media_type, media_source=media_source),
)
assert evaluation.result.recommended.category_id == expected_category
def test_unavailable_source_field_is_rejected() -> None:
"""限定到完全不支持字段的来源时,策略不得发布。"""
result = _validation_report(
_policy(
_category_rule(
"rule.unavailable",
_leaf("media.runtime", "gte", 90),
media_types=["电影"],
sources=["tvdb"],
)
).model_dump(mode="json")
)
assert result.valid is False
assert any(issue.code == "unavailable_field_support" for issue in result.issues)
def test_trace_reports_actual_values_and_match_decisions() -> None:
evaluation = _evaluate(
_policy(
_category_rule(
"rule.trace",
_leaf("media.year", "equals", 1999),
)
),
trace=True,
)
assert evaluation.trace[0].rule_id == "rule.trace"
assert evaluation.trace[0].matched is False
assert evaluation.trace[0].conditions[0].field == "media.year"
assert evaluation.trace[0].conditions[0].actual == 2001
assert evaluation.trace[0].conditions[0].matched is False
def _validation_report(
payload: dict[str, Any],
*,
fields: Sequence[ClassificationFieldDefinition] | None = None,
) -> ClassificationValidationResult:
standard_fields = get_standard_classification_fields()
extra_fields = tuple(fields or ())
if extra_fields[:len(standard_fields)] == standard_fields:
extra_fields = extra_fields[len(standard_fields):]
return ClassificationPolicyValidator.validate(
ClassificationPolicy.model_validate(payload),
extra_fields=extra_fields,
)
def _assert_validation_error(
payload: dict[str, Any],
expected_code: str,
*,
fields: Sequence[ClassificationFieldDefinition] | None = None,
) -> None:
report = _validation_report(payload, fields=fields)
error_codes = {
issue.code for issue in report.issues if issue.severity == "error"
}
assert report.valid is False
assert expected_code in error_codes, error_codes
def test_valid_policy_has_no_validation_errors() -> None:
payload = _base_policy_payload()
payload["rules"] = [
_category_rule(
"rule.valid",
_leaf("media.genre_keys", "contains_any", ["animation"]),
sources=["themoviedb"],
)
]
report = _validation_report(payload)
assert report.valid is True
assert report.issues == []
@pytest.mark.parametrize( # type: ignore[misc]
("mutate", "expected_code"),
[
(
lambda payload: payload["categories"].append(
copy.deepcopy(payload["categories"][0])
),
"duplicate_category_id",
),
(
lambda payload: payload["rules"].append(
copy.deepcopy(payload["rules"][0])
),
"duplicate_rule_id",
),
],
ids=["category-id", "rule-id"],
)
def test_duplicate_ids_are_rejected(
mutate: Callable[[dict[str, Any]], None],
expected_code: str,
) -> None:
payload = _base_policy_payload()
payload["rules"] = [
_category_rule("rule.duplicate", _leaf("media.year", "gte", 2000))
]
mutate(payload)
_assert_validation_error(payload, expected_code)
@pytest.mark.parametrize( # type: ignore[misc]
"path",
[
[],
[""],
["."],
[".."],
["动画/电影"],
["动画\\电影"],
["非法\x00路径"],
["x" * 65],
["", "", "", "", ""],
],
)
def test_invalid_category_paths_are_rejected(path: list[str]) -> None:
payload = _base_policy_payload()
payload["categories"][0]["path"] = path
_assert_validation_error(payload, "invalid_category_path")
def test_duplicate_paths_are_rejected_within_the_same_media_type() -> None:
payload = _base_policy_payload()
payload["categories"][1]["path"] = payload["categories"][0]["path"]
_assert_validation_error(payload, "duplicate_category_path")
@pytest.mark.parametrize( # type: ignore[misc]
("target", "expected_code"),
[
("movie.unknown", "target_category_not_found"),
("tv.fallback", "target_media_type_mismatch"),
],
ids=["not-found", "cross-media-type"],
)
def test_category_rule_targets_must_exist_and_match_the_rule_media_type(
target: str,
expected_code: str,
) -> None:
payload = _base_policy_payload()
payload["rules"] = [
_category_rule(
"rule.invalid-target",
_leaf("media.year", "gte", 2000),
target,
)
]
_assert_validation_error(payload, expected_code)
@pytest.mark.parametrize( # type: ignore[misc]
("condition", "expected_code"),
[
(_leaf("media.unregistered", "equals", "x"), "unknown_field"),
(_leaf("media.year", "contains", "20"), "unsupported_operator"),
(_leaf("media.year", "between", ["old", 2020]), "invalid_condition_value"),
(_leaf("media.language", "in", []), "empty_membership_value"),
(_leaf("media.year", "between", [2025, 2000]), "invalid_between_range"),
],
ids=[
"unknown-field",
"unsupported-operator",
"wrong-value-type",
"empty-in",
"reversed-between",
],
)
def test_condition_contract_errors_are_rejected(
condition: dict[str, Any],
expected_code: str,
) -> None:
payload = _base_policy_payload()
payload["rules"] = [_category_rule("rule.invalid-condition", condition)]
_assert_validation_error(payload, expected_code)
def test_condition_tree_depth_limit_is_enforced() -> None:
condition: dict[str, Any] = _leaf("media.year", "gte", 2000)
for _ in range(4):
condition = {"not": condition}
payload = _base_policy_payload()
payload["rules"] = [_category_rule("rule.too-deep", condition)]
_assert_validation_error(payload, "max_depth_exceeded")
def test_policy_rule_count_limit_is_enforced() -> None:
payload = _base_policy_payload()
payload["rules"] = [
_category_rule(
f"rule.too-many.{index}",
_leaf("media.year", "gte", 2000),
)
for index in range(1001)
]
_assert_validation_error(payload, "max_rules_exceeded")
def test_per_rule_leaf_condition_limit_is_enforced() -> None:
payload = _base_policy_payload()
payload["rules"] = [
_category_rule(
"rule.too-many-conditions",
{
"all": [
_leaf("media.year", "gte", 1900)
for _ in range(31)
]
},
)
]
_assert_validation_error(payload, "max_conditions_exceeded")
def test_every_enabled_media_type_requires_a_fallback() -> None:
payload = _base_policy_payload()
payload["fallbacks"].pop("音乐")
_assert_validation_error(payload, "missing_fallback")
def test_extension_field_namespace_must_match_the_restricted_source() -> None:
standard_fields = list(get_standard_classification_fields())
field_model = type(standard_fields[0])
extension_field = field_model.model_validate(
{
"id": "extensions.example.source.region_group",
"label": "来源地区组",
"value_type": "string",
"operators": ["equals", "in", "exists", "not_exists"],
"media_types": ["电影"],
"options": [],
"source_support": {"example.source": "extension"},
}
)
payload = _base_policy_payload()
payload["rules"] = [
_category_rule(
"rule.bad-extension-source",
_leaf(
"extensions.example.source.region_group",
"equals",
"east-asia",
),
sources=["other.source"],
)
]
_assert_validation_error(
payload,
"extension_namespace_mismatch",
fields=[*standard_fields, extension_field],
)
def test_standard_field_catalog_is_unique_and_exposes_operator_contracts() -> None:
fields = get_standard_classification_fields()
field_map = {field.id: field for field in fields}
assert len(field_map) == len(fields)
assert "contains_none" in field_map["media.countries"].operators
assert "between" in field_map["media.year"].operators
assert "in" in field_map["media.year"].operators
assert "not_in" in field_map["media.year"].operators
assert "is_false" in field_map["media.adult"].operators
assert set(field_map["music.secondary_types"].media_types) == {"音乐"}
def _percentile_95(samples: list[float]) -> float:
ordered = sorted(samples)
return ordered[max(0, int(len(ordered) * 0.95) - 1)]
def test_two_hundred_rules_with_six_conditions_evaluate_under_five_ms_p95() -> None:
rules = []
for index in range(200):
rules.append(
_category_rule(
f"rule.performance.{index}",
{
"all": [
_leaf("media.year", "gte", 1900),
_leaf("media.year", "lte", 2100),
_leaf("media.language", "in", ["ja", "en"]),
_leaf("media.countries", "contains_any", ["JP"]),
_leaf("media.genre_keys", "contains_any", ["animation"]),
_leaf("media.content_rating", "equals", f"rating-{index}"),
]
},
)
)
evaluator = ClassificationEvaluator()
policy = _policy(*rules)
facts = _facts()
for _ in range(20):
evaluator.evaluate(policy=policy, facts=facts, trace=False)
batch_p95_values = []
gc_was_enabled = gc.isenabled()
gc.disable()
try:
for _ in range(5):
samples = []
for _ in range(40):
started_at = time.perf_counter_ns()
evaluator.evaluate(policy=policy, facts=facts, trace=False)
samples.append((time.perf_counter_ns() - started_at) / 1_000_000_000)
batch_p95_values.append(_percentile_95(samples))
finally:
if gc_was_enabled:
gc.enable()
median_p95 = statistics.median(batch_p95_values)
assert median_p95 < 0.005, (
f"200 rules x 6 conditions median P95 was {median_p95 * 1000:.3f} ms; "
f"batch P95 values were {[round(value * 1000, 3) for value in batch_p95_values]} ms"
)