Files
MoviePilot/tests/test_deprecation.py
T

342 lines
11 KiB
Python

"""渐进式废弃登记的阶段行为,以及与旧 Facade 命中观测的联动测试。"""
import asyncio
import inspect
from dataclasses import dataclass, field
from typing import Dict, List, Mapping, Tuple
import pytest
from app.runtime.config import settings
from app.runtime.deprecation import notices as notices_module
from app.runtime.deprecation import policy
from app.runtime.deprecation.notices import NOTICES, DeprecationNotice, DeprecationStage
from app.runtime.deprecation.policy import (
DeprecatedFeatureError,
all_notices,
deprecated,
enforce_facade,
guard,
is_active,
warn,
)
from app.runtime.observability import (
MetricSpec,
configure_observation,
observe_compat_facade,
)
@dataclass
class _RecordingLogger:
"""只记录 warning 文本的日志替身。"""
messages: List[str] = field(default_factory=list)
def warning(self, message: str) -> None:
"""记录一条告警文本。"""
self.messages.append(message)
@dataclass
class _RecordingObservationPort:
"""保存已经通过标签合同校验的指标写入。"""
records: List[Tuple[str, Dict[str, str]]] = field(default_factory=list)
def record(self, spec: MetricSpec, value: float, labels: Mapping[str, str]) -> None:
"""追加一条不可变测试快照。"""
self.records.append((spec.name, dict(labels)))
@pytest.fixture(autouse=True)
def _reset_warned():
"""避免告警去重记录在用例间泄漏。"""
policy.reset_warned()
yield
policy.reset_warned()
@pytest.fixture
def warnings_log(monkeypatch) -> _RecordingLogger:
"""把废弃告警接到可断言的日志替身上。"""
recorder = _RecordingLogger()
monkeypatch.setattr(policy, "logger", recorder)
return recorder
@pytest.fixture
def observation_port():
"""安装可断言的观测端口,用例结束恢复 no-op。"""
port = _RecordingObservationPort()
configure_observation(port)
yield port
configure_observation(None)
@pytest.fixture
def registry(monkeypatch):
"""
用可控登记表替换全局登记表,避免用例依赖真实登记内容。
:return: ``register(stage, key) -> DeprecationNotice``
"""
table: Dict[str, DeprecationNotice] = {}
monkeypatch.setattr(notices_module, "NOTICES", table)
def register(stage: DeprecationStage, key: str = "demo.legacy") -> DeprecationNotice:
"""
登记一条指定阶段的废弃通告
:param stage: 所处阶段
:param key: 废弃标识
:return: 登记
"""
notice = DeprecationNotice(
key=key,
subject="示例旧入口",
stage=stage,
since="v3.0.0",
replacement="示例新入口",
reason="仅用于测试",
)
table[key] = notice
return notice
return register
def test_silent_stage_stays_active_and_quiet(registry, warnings_log) -> None:
"""仅登记阶段不改变行为,也不产生任何告警。"""
registry(DeprecationStage.SILENT)
assert is_active("demo.legacy") is True
guard("demo.legacy")
warn("demo.legacy")
assert warnings_log.messages == []
def test_warn_stage_logs_once_per_context(registry, warnings_log) -> None:
"""预警阶段功能照常,同一来源只留一次痕迹,不同来源各留一次。"""
registry(DeprecationStage.WARN)
assert is_active("demo.legacy") is True
warn("demo.legacy", context="PluginA")
warn("demo.legacy", context="PluginA")
warn("demo.legacy", context="PluginB")
assert len(warnings_log.messages) == 2
assert "示例旧入口 自 v3.0.0 起进入废弃流程" in warnings_log.messages[0]
assert "移除版本待定" in warnings_log.messages[0]
assert "触发来源:PluginA" in warnings_log.messages[0]
assert "请改用:示例新入口" in warnings_log.messages[0]
assert "触发来源:PluginB" in warnings_log.messages[1]
def test_disabled_stage_blocks_until_explicitly_enabled(registry, monkeypatch) -> None:
"""停用阶段默认抛错,仅在标识写进 DEPRECATION_ENABLED 后恢复。"""
registry(DeprecationStage.DISABLED)
monkeypatch.setattr(settings, "DEPRECATION_ENABLED", None)
assert is_active("demo.legacy") is False
with pytest.raises(DeprecatedFeatureError, match="已默认停用"):
guard("demo.legacy")
monkeypatch.setattr(settings, "DEPRECATION_ENABLED", "other.key, demo.legacy")
assert is_active("demo.legacy") is True
guard("demo.legacy")
def test_removed_stage_cannot_be_restored(registry, monkeypatch) -> None:
"""移除阶段无视开关,一律抛错并说明无法恢复。"""
registry(DeprecationStage.REMOVED)
monkeypatch.setattr(settings, "DEPRECATION_ENABLED", "demo.legacy")
assert is_active("demo.legacy") is False
with pytest.raises(DeprecatedFeatureError, match="已彻底移除"):
guard("demo.legacy")
def test_unregistered_key_is_rejected_by_lookups(registry) -> None:
"""未登记标识在查询入口上直接报错,避免登记表与调用点写法漂移。"""
registry(DeprecationStage.WARN)
with pytest.raises(KeyError, match="未登记的废弃标识"):
is_active("demo.missing")
with pytest.raises(KeyError, match="未登记的废弃标识"):
warn("demo.missing")
with pytest.raises(KeyError, match="未登记的废弃标识"):
guard("demo.missing")
def test_unregistered_key_passes_through_enforcement(registry, warnings_log) -> None:
"""未纳入废弃流程的触达点原样放行,既不报错也不留痕。"""
registry(DeprecationStage.DISABLED)
policy.enforce("demo.missing")
enforce_facade("OtherFacade", "any_method")
assert warnings_log.messages == []
def test_deprecated_decorator_keeps_call_and_metadata(registry, warnings_log) -> None:
"""预警阶段的装饰器留痕后照常执行,并保留原函数元数据。"""
registry(DeprecationStage.WARN)
@deprecated("demo.legacy")
def legacy_call(value: int) -> int:
"""返回入参本身。"""
return value
assert legacy_call(3) == 3
assert legacy_call(4) == 4
assert legacy_call.__name__ == "legacy_call"
assert len(warnings_log.messages) == 1
def test_deprecated_decorator_blocks_disabled_stage(registry, monkeypatch) -> None:
"""停用阶段的装饰器直接拦截,不再执行原实现。"""
registry(DeprecationStage.DISABLED)
monkeypatch.setattr(settings, "DEPRECATION_ENABLED", None)
calls: List[int] = []
@deprecated("demo.legacy")
def legacy_call() -> None:
"""记录一次实际执行。"""
calls.append(1)
with pytest.raises(DeprecatedFeatureError):
legacy_call()
assert calls == []
def test_deprecated_decorator_keeps_async_call_shape(registry, warnings_log) -> None:
"""异步函数经装饰后仍可被调度器识别为协程函数。"""
registry(DeprecationStage.WARN)
@deprecated("demo.legacy")
async def legacy_call(value: int) -> int:
"""返回入参本身。"""
return value
assert inspect.iscoroutinefunction(legacy_call) is True
assert asyncio.run(legacy_call(3)) == 3
assert len(warnings_log.messages) == 1
def test_compat_facade_hit_is_recorded_before_stage_is_applied(
registry, monkeypatch, observation_port
) -> None:
"""停用阶段仍先记账再拦截,命中数不会因为收口而丢失。"""
registry(DeprecationStage.DISABLED, key="DemoFacade.legacy_call")
monkeypatch.setattr(settings, "DEPRECATION_ENABLED", None)
@observe_compat_facade("DemoFacade")
class DemoFacade:
"""被观测的旧 Facade 替身。"""
@staticmethod
def legacy_call() -> str:
"""返回固定结果。"""
return "called"
with pytest.raises(DeprecatedFeatureError):
DemoFacade.legacy_call()
assert observation_port.records == [
(
"compat.facade.hit",
{
"facade": "DemoFacade",
"operation": "legacy_call",
"visibility": "public",
"abi_source": "legacy_facade",
},
)
]
def test_facade_notice_covers_every_operation(registry, warnings_log) -> None:
"""整个 Facade 的登记覆盖其所有方法,且每个方法各留一次痕迹。"""
registry(DeprecationStage.WARN, key="DemoFacade")
@observe_compat_facade("DemoFacade")
class DemoFacade:
"""被观测的旧 Facade 替身。"""
@staticmethod
def first() -> None:
"""空实现。"""
@staticmethod
def second() -> None:
"""空实现。"""
DemoFacade.first()
DemoFacade.first()
DemoFacade.second()
assert len(warnings_log.messages) == 2
assert "触发来源:DemoFacade.first" in warnings_log.messages[0]
assert "触发来源:DemoFacade.second" in warnings_log.messages[1]
def test_method_notice_takes_precedence_over_facade_notice(registry, warnings_log) -> None:
"""同时登记 Facade 与其单个方法时,按方法级登记处置。"""
registry(DeprecationStage.WARN, key="DemoFacade")
registry(DeprecationStage.REMOVED, key="DemoFacade.legacy_call")
@observe_compat_facade("DemoFacade")
class DemoFacade:
"""被观测的旧 Facade 替身。"""
@staticmethod
def legacy_call() -> None:
"""空实现。"""
@staticmethod
def other_call() -> None:
"""空实现。"""
with pytest.raises(DeprecatedFeatureError, match="已彻底移除"):
DemoFacade.legacy_call()
DemoFacade.other_call()
assert "触发来源:DemoFacade.other_call" in warnings_log.messages[0]
def test_async_facade_method_applies_stage(registry, monkeypatch, observation_port) -> None:
"""异步旧 Facade 方法同样先记账再按阶段拦截。"""
registry(DeprecationStage.DISABLED, key="DemoFacade.async_call")
monkeypatch.setattr(settings, "DEPRECATION_ENABLED", None)
@observe_compat_facade("DemoFacade")
class DemoFacade:
"""被观测的旧 Facade 替身。"""
@staticmethod
async def async_call() -> str:
"""返回固定结果。"""
return "called"
with pytest.raises(DeprecatedFeatureError):
asyncio.run(DemoFacade.async_call())
assert observation_port.records[0][1]["operation"] == "async_call"
def test_registered_notices_are_self_consistent() -> None:
"""真实登记表的键与登记本身一致,且每条都给出替代方案与原因。"""
assert NOTICES
for key, notice in NOTICES.items():
assert key == notice.key
assert notice.since
assert notice.replacement
assert notice.reason
assert isinstance(notice.stage, DeprecationStage)
assert all_notices() == tuple(NOTICES[key] for key in sorted(NOTICES))