mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/jxxghp/MoviePilot.git
synced 2026-09-20 08:03:34 +08:00
191 lines
7.0 KiB
Python
191 lines
7.0 KiB
Python
"""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",
|
||
]
|