Files
MoviePilot/tests/test_transfer_architecture.py

191 lines
7.0 KiB
Python
Raw 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.

"""TransferChain 同名包的职责、公开面与兼容身份门禁。"""
import ast
import importlib
import pickle
from pathlib import Path
PROJECT_ROOT = Path(__file__).parents[1]
CHAIN_ROOT = PROJECT_ROOT / "app" / "chain"
TRANSFER_PACKAGE = CHAIN_ROOT / "transfer"
def _class_methods(path: Path, class_name: str) -> set[str]:
"""读取指定 owner 类直接定义的方法集合。"""
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
class_node = next(
node
for node in tree.body
if isinstance(node, ast.ClassDef) and node.name == class_name
)
return {
node.name
for node in class_node.body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
}
def test_transfer_package_retires_monolith_and_keeps_thin_facade() -> None:
"""旧单体不得复活Facade 只允许保留三个可替换 Chain 构造点。"""
assert not (CHAIN_ROOT / "transfer.py").exists()
assert not (CHAIN_ROOT / "_transfer.py").exists()
assert {
path.name for path in TRANSFER_PACKAGE.glob("*.py")
} == {
"__init__.py",
"checkpoint.py",
"contract.py",
"execution.py",
"facade.py",
"filter.py",
"format.py",
"history.py",
"plan.py",
"queue.py",
"records.py",
"request.py",
"retry.py",
"scrape.py",
"settlement.py",
"workflow.py",
}
assert all(
path.stem == "__init__" or path.stem.isalpha()
for path in TRANSFER_PACKAGE.glob("*.py")
)
facade_path = TRANSFER_PACKAGE / "facade.py"
assert len(facade_path.read_text(encoding="utf-8").splitlines()) <= 100
assert _class_methods(facade_path, "TransferChain") == {
"_transfer_media_chain",
"_transfer_storage_chain",
"_transfer_subscribe_chain",
}
def test_transfer_package_root_exposes_only_stable_abi() -> None:
"""包根只保留 Chain 类型与现有插件使用的任务锁兼容入口。"""
package = importlib.import_module("app.chain.transfer")
queue_module = importlib.import_module("app.chain.transfer.queue")
assert package.__all__ == ["TransferChain", "task_lock"]
assert package.TransferChain.__module__ == "app.chain.transfer"
assert package.task_lock is queue_module.task_lock
assert pickle.loads(pickle.dumps(package.TransferChain)) is package.TransferChain
for internal_name in (
"JobManager",
"TransferExecutionOwner",
"TransferHistoryOwner",
"TransferPlanningOwner",
"TransferQueueOwner",
"TransferSettlementOwner",
"TransferWorkflowOwner",
"_DurableTransferStepRunner",
"_TransferOwnerHost",
):
assert not hasattr(package, internal_name)
def test_transfer_chain_keeps_compatible_mro() -> None:
"""拆分后 mixin 顺序与历史方法解析顺序必须保持稳定。"""
chain_type = importlib.import_module("app.chain.transfer").TransferChain
owner_types = chain_type.__mro__[1:14]
assert [base.__name__ for base in chain_type.__mro__[:15]] == [
"TransferChain",
"FileFilterMixin",
"ScrapeBatchMixin",
"EpisodeFormatMixin",
"HistoryMatchMixin",
"FileKeyMixin",
"ManualHistoryMixin",
"FailedRetryMixin",
"TransferQueueOwner",
"TransferPlanningOwner",
"TransferExecutionOwner",
"TransferSettlementOwner",
"TransferWorkflowOwner",
"TransferHistoryOwner",
"ChainBase",
]
assert [
f"{owner.__name__}.{name}"
for owner in owner_types
for name in vars(owner)
if name.startswith(f"_{owner.__name__}__")
] == []
assert "_TransferOwnerHost" not in {
owner.__name__ for owner in chain_type.__mro__
}
def test_transfer_workflows_have_one_method_owner() -> None:
"""每个拆分职责的方法只能由一个 owner 定义,避免复制实现与 MRO 遮蔽。"""
owner_classes = {
"execution.py": "TransferExecutionOwner",
"history.py": "TransferHistoryOwner",
"plan.py": "TransferPlanningOwner",
"queue.py": "TransferQueueOwner",
"settlement.py": "TransferSettlementOwner",
"workflow.py": "TransferWorkflowOwner",
}
method_owners: dict[str, list[str]] = {}
for filename, class_name in owner_classes.items():
for method_name in _class_methods(TRANSFER_PACKAGE / filename, class_name):
method_owners.setdefault(method_name, []).append(filename)
assert {
name: owners for name, owners in method_owners.items() if len(owners) != 1
} == {}
chain_type = importlib.import_module("app.chain.transfer").TransferChain
expected_modules = {
"put_to_queue": "app.chain.transfer.queue",
"replay_pending": "app.chain.transfer.queue",
"process": "app.chain.transfer.queue",
"_plan_checkpoint_and_execute": "app.chain.transfer.plan",
"execute_legacy_transfer_command": "app.chain.transfer.plan",
"_TransferChain__handle_transfer": "app.chain.transfer.execution",
"_TransferChain__perform_transfer": "app.chain.transfer.execution",
"_publish_transfer_result": "app.chain.transfer.settlement",
"queue_failed_transfer_notification": "app.chain.transfer.settlement",
"do_transfer": "app.chain.transfer.workflow",
"_execute_transfer": "app.chain.transfer.workflow",
"remote_transfer": "app.chain.transfer.history",
"manual_transfer": "app.chain.transfer.history",
"send_transfer_message": "app.chain.transfer.history",
}
assert {
method_name: getattr(chain_type, method_name).__module__
for method_name in expected_modules
} == expected_modules
def test_transfer_cross_cutting_effects_have_single_owners() -> None:
"""线程锁和终态事件必须分别只由队列、结算 owner 持有。"""
lock_definitions: list[str] = []
event_methods: list[str] = []
for path in sorted(TRANSFER_PACKAGE.glob("*.py")):
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
for node in ast.walk(tree):
if isinstance(node, (ast.Assign, ast.AnnAssign)):
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
for target in targets:
if isinstance(target, ast.Name) and target.id in {
"downloader_lock",
"task_lock",
}:
lock_definitions.append(f"{path.name}:{target.id}")
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in {
"_durable_transfer_event",
"_publish_transfer_result",
}:
event_methods.append(f"{path.name}:{node.name}")
assert lock_definitions == [
"queue.py:task_lock",
"queue.py:downloader_lock",
]
assert event_methods == [
"settlement.py:_durable_transfer_event",
"settlement.py:_publish_transfer_result",
]