Files
MoviePilot/tests/test_filemanager_planning.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

798 lines
24 KiB
Python
Raw Permalink 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.
"""
验证文件整理计划的输入冻结、操作顺序、事件拦截和清理兼容。
"""
from dataclasses import replace
from pathlib import Path
from unittest.mock import Mock
import pytest
from app.application.transfer.workflow import (
TransferPlanCheckpoint,
TransferPlanItem,
TransferPlanningInput,
)
from app.domain.context import MediaInfo
from app.domain.meta.metabase import MetaBase
from app.modules.filemanager import transhandler as transhandler_module
from app.modules.filemanager.module import FileManagerModule
from app.modules.filemanager.transhandler import TransHandler
from app.runtime.events import eventmanager
from app.schemas.system import TransferDirectoryConf
from app.schemas.types import ChainEventType, MediaType
from app.schemas.workflow import FileItem
class ReadOnlyTreeStorage:
"""
提供目录只读遍历,并让任何写接口调用立即失败。
"""
def __init__(self, children: dict[str, list[FileItem]]):
"""
保存按源目录路径索引的测试树。
"""
self.children = children
self.reads: list[str] = []
def list(self, fileitem: FileItem) -> list[FileItem]:
"""
返回目录孩子并记录只读访问。
"""
self.reads.append(fileitem.path)
return self.children.get(fileitem.path, [])
def __getattr__(self, name: str):
"""
拒绝规划期意外访问的存储接口。
"""
raise AssertionError(f"规划阶段不应访问存储接口:{name}")
class RecordingStorage:
"""
记录执行期存储调用顺序并模拟同一网盘复制。
"""
def __init__(self, calls: list[str], existing: FileItem = None):
"""
保存调用日志与可选同名目标。
"""
self.calls = calls
self.files = {existing.path: existing} if existing else {}
def get_item_strict(self, path: Path):
"""
记录严格目标查询。
"""
self.calls.append("strict")
return self.files.get(path.as_posix())
def get_item(self, path: Path):
"""
记录普通目标查询。
"""
self.calls.append("get_item")
return self.files.get(path.as_posix())
def get_folder(self, path: Path) -> FileItem:
"""
记录可能创建目录的接口。
"""
self.calls.append("get_folder")
return FileItem(
storage="alist",
path=path.as_posix(),
name=path.name,
type="dir",
)
def delete(self, fileitem: FileItem) -> bool:
"""
记录删除副作用。
"""
self.calls.append("delete")
self.files.pop(fileitem.path, None)
return True
def rename(self, fileitem: FileItem, name: str) -> bool:
"""
模拟拒绝覆盖已有目标的同目录重命名。
"""
target = Path(fileitem.path).with_name(name)
self.calls.append(f"rename:{target.as_posix()}")
assert target.as_posix() not in self.files
source = self.files.pop(fileitem.path)
self.files[target.as_posix()] = source.model_copy(update={"path": target.as_posix(), "name": name})
return True
def is_support_transtype(self, transfer_type: str) -> bool:
"""
声明测试存储支持复制。
"""
return transfer_type == "copy"
def copy(self, fileitem: FileItem, path: Path, name: str) -> bool:
"""
记录复制副作用。
"""
self.calls.append(f"copy:{(path / name).as_posix()}")
self.files[(path / name).as_posix()] = fileitem.model_copy(update={
"path": (path / name).as_posix(), "name": name,
})
return True
def _build_media() -> tuple[MetaBase, MediaInfo]:
"""
构造文件规划所需的最小电视剧领域对象。
"""
meta = MetaBase("Test.Show.S01E01.mkv")
meta.type = MediaType.TV
meta.name = "Test Show"
meta.year = "2026"
meta.begin_season = 1
meta.begin_episode = 1
mediainfo = MediaInfo(
type=MediaType.TV,
title="Test Show",
year="2026",
tmdb_id=12345,
)
return meta, mediainfo
def _build_fileitem() -> FileItem:
"""
构造无需访问宿主文件系统的网盘源文件。
"""
return FileItem(
storage="alist",
path="/downloads/Test.Show.S01E01.mkv",
name="Test.Show.S01E01.mkv",
basename="Test.Show.S01E01",
extension="mkv",
type="file",
size=1024,
modify_time=1.0,
)
def _build_input(fileitem: FileItem, **overrides) -> TransferPlanningInput:
"""
构造可持久化规划输入。
"""
values = {
"source_fileitem": fileitem.model_dump(mode="json"),
"target_storage": "alist",
"target_path": "/library",
"requested_transfer_type": "copy",
"need_rename": True,
"overwrite_mode": "always",
}
values.update(overrides)
return TransferPlanningInput(**values)
def _plan_file(
handler: TransHandler,
planning_input: TransferPlanningInput,
meta: MetaBase,
mediainfo: MediaInfo,
source_oper,
):
"""
使用测试固定策略规划单个文件。
"""
return handler.plan_transfer(
planning_input,
meta=meta,
mediainfo=mediainfo,
source_oper=source_oper,
target_storage="alist",
target_path=Path("/library"),
transfer_type="copy",
need_scrape=False,
need_rename=True,
need_notify=True,
overwrite_mode="always",
episodes_info=None,
preview=False,
)
def test_directory_planning_only_reads_source_and_freezes_ordered_leaf_operations():
"""
目录规划只读取源目录,并冻结有序的叶子文件操作。
"""
root = FileItem(storage="alist", path="/source/disc", name="disc", type="dir")
nested = FileItem(storage="alist", path="/source/disc/BDMV", name="BDMV", type="dir")
first = FileItem(
storage="alist",
path="/source/disc/BDMV/index.bdmv",
name="index.bdmv",
type="file",
extension="bdmv",
)
second = FileItem(
storage="alist",
path="/source/disc/MovieObject.bdmv",
name="MovieObject.bdmv",
type="file",
extension="bdmv",
)
storage = ReadOnlyTreeStorage(
{
root.path: [nested, second],
nested.path: [first],
}
)
meta, mediainfo = _build_media()
planning_input = _build_input(root, need_rename=False)
checkpoint = TransHandler().plan_transfer(
planning_input,
meta=meta,
mediainfo=mediainfo,
source_oper=storage,
target_storage="alist",
target_path=Path("/library"),
transfer_type="copy",
need_scrape=False,
need_rename=False,
need_notify=True,
overwrite_mode="never",
episodes_info=None,
preview=False,
)
assert storage.reads == [root.path, nested.path]
assert [item.sequence for item in checkpoint.items] == [0, 1]
assert [item.source_fileitem["path"] for item in checkpoint.items] == [
first.path,
second.path,
]
assert [item.target_path for item in checkpoint.items] == [
"/library/disc/BDMV/index.bdmv",
"/library/disc/MovieObject.bdmv",
]
def test_execute_uses_frozen_target_and_intercepts_before_directory_or_delete(
monkeypatch,
):
"""
执行阶段使用冻结目标,在创建目录和覆盖删除前完成拦截。
"""
calls: list[str] = []
fileitem = _build_fileitem()
meta, mediainfo = _build_media()
def record_event(event_type, event_data):
"""
记录规划和执行事件顺序。
"""
calls.append(event_type.value)
return None
monkeypatch.setattr(eventmanager, "send_event", record_event)
handler = TransHandler()
checkpoint = _plan_file(
handler,
_build_input(fileitem),
meta,
mediainfo,
ReadOnlyTreeStorage({}),
)
frozen_target = checkpoint.final_target_path
assert calls == [
ChainEventType.TransferRenameBuild.value,
ChainEventType.TransferRename.value,
]
calls.clear()
original_get_runtime_setting = transhandler_module.get_runtime_setting
def drifted_runtime_setting(name: str):
"""
让执行期重新读取重命名配置时立即失败。
"""
if name == "RENAME_FORMAT":
raise AssertionError("执行冻结计划不应重新读取重命名配置")
return original_get_runtime_setting(name)
monkeypatch.setattr(
transhandler_module,
"get_runtime_setting",
drifted_runtime_setting,
)
existing = FileItem(
storage="alist",
path=frozen_target,
name=Path(frozen_target).name,
type="file",
extension="mkv",
size=100,
modify_time=1.0,
)
storage = RecordingStorage(calls, existing=existing)
result = handler.execute_transfer_plan(
checkpoint,
meta=meta,
mediainfo=mediainfo,
source_oper=storage,
target_oper=storage,
)
assert result.success is True
assert ChainEventType.TransferRenameBuild.value not in calls
assert ChainEventType.TransferRename.value not in calls
assert ChainEventType.TransferOverwriteCheck.value in calls
intercept_index = calls.index(ChainEventType.TransferIntercept.value)
assert intercept_index < calls.index("get_folder")
assert intercept_index < calls.index("delete")
assert f"rename:{frozen_target}" in calls
assert next(index for index, call in enumerate(calls) if call.startswith("copy:")) < calls.index("delete")
def test_module_preserves_admission_input_while_freezing_resolved_directory():
"""
规划保留原始准入输入,同时冻结解析后的目录和媒体信息。
"""
fileitem = _build_fileitem()
meta, mediainfo = _build_media()
admission_input = _build_input(
fileitem,
target_storage=None,
target_path=None,
requested_transfer_type=None,
target_directory={"name": "library"},
)
fingerprint = admission_input.fingerprint
target_directory = TransferDirectoryConf(
name="library",
transfer_type="copy",
overwrite_mode="never",
library_path="/resolved-library",
library_storage="alist",
renaming=True,
scraping=False,
notify=True,
)
checkpoint = FileManagerModule().plan_transfer(
fileitem=fileitem,
meta=meta,
mediainfo=mediainfo,
target_directory=target_directory,
source_oper=ReadOnlyTreeStorage({}),
planning_input=admission_input,
)
assert checkpoint.planning_input is admission_input
assert checkpoint.planning_input.fingerprint == fingerprint
assert checkpoint.root_target_path == "/resolved-library"
assert checkpoint.final_target_path.startswith("/resolved-library/")
assert checkpoint.resolved_meta_kind == "MetaBase"
assert checkpoint.resolved_meta["begin_episode"] == 1
assert checkpoint.resolved_mediainfo_kind == "MediaInfo"
assert checkpoint.resolved_mediainfo["title"] == "Test Show"
def _build_cleanup_checkpoint(monkeypatch, calls: list[str]):
"""
构造带冻结旧目标清理意图的单文件检查点。
"""
fileitem = _build_fileitem()
meta, mediainfo = _build_media()
cleanup_item = FileItem(
storage="cleanup",
path="/old-library/old.mkv",
name="old.mkv",
type="file",
extension="mkv",
)
planning_input = _build_input(
fileitem,
options={"cleanup_dest_fileitem": cleanup_item.model_dump(mode="json")},
)
def record_event(event_type, event_data):
"""
记录执行事件顺序。
"""
calls.append(event_type.value)
return None
monkeypatch.setattr(eventmanager, "send_event", record_event)
checkpoint = _plan_file(
TransHandler(),
planning_input,
meta,
mediainfo,
ReadOnlyTreeStorage({}),
)
calls.clear()
return checkpoint, meta, mediainfo, cleanup_item
def test_cleanup_runs_after_new_target_is_materialized(monkeypatch):
"""
宿主重整理只能在新目标已完整落地后清理旧历史目的。
"""
calls: list[str] = []
checkpoint, meta, mediainfo, cleanup_item = _build_cleanup_checkpoint(
monkeypatch,
calls,
)
transfer_storage = RecordingStorage(calls)
module = FileManagerModule()
cleaned_items: list[FileItem] = []
def cleanup_media_file(fileitem: FileItem) -> bool:
"""
模拟含目录保护与插件路由的统一删除兼容能力。
"""
calls.append("cleanup_compat")
cleaned_items.append(fileitem)
return True
result = module.execute_transfer_plan(
checkpoint,
meta=meta,
mediainfo=mediainfo,
source_oper=transfer_storage,
target_oper=transfer_storage,
cleanup_media_file=cleanup_media_file,
)
assert result.success is True
intercept_index = calls.index(ChainEventType.TransferIntercept.value)
assert intercept_index < calls.index("cleanup_compat")
assert calls.index("get_folder") < calls.index("cleanup_compat")
assert calls.index("cleanup_compat") > next(
index for index, call in enumerate(calls) if call.startswith("copy:")
)
assert cleaned_items == [cleanup_item]
def test_cleanup_compatibility_capability_can_report_idempotent_success(monkeypatch):
"""
旧清理能力缺失时可报告幂等成功,不阻断已完成的整理。
"""
calls: list[str] = []
checkpoint, meta, mediainfo, _ = _build_cleanup_checkpoint(monkeypatch, calls)
transfer_storage = RecordingStorage(calls)
module = FileManagerModule()
def cleanup_media_file(fileitem: FileItem) -> bool:
"""
统一能力将目标不存在归一为幂等成功。
"""
calls.append("cleanup_compat_missing")
return True
result = module.execute_transfer_plan(
checkpoint,
meta=meta,
mediainfo=mediainfo,
source_oper=transfer_storage,
target_oper=transfer_storage,
cleanup_media_file=cleanup_media_file,
)
assert result.success is True
assert "cleanup_compat_missing" in calls
def test_replayed_checkpoint_skips_cleanup_already_completed_before_provider(
monkeypatch,
):
"""
provider 前已完成 cleanup 的持久检查点在宿主重放时不得再次删除。
"""
calls: list[str] = []
checkpoint, meta, mediainfo, _ = _build_cleanup_checkpoint(monkeypatch, calls)
persisted_checkpoint = TransferPlanCheckpoint.from_payload(
replace(
checkpoint,
pre_execution_cleanup_completed=True,
).to_payload()
)
transfer_storage = RecordingStorage(calls)
def unexpected_cleanup(_fileitem: FileItem) -> bool:
"""
若持久完成事实未被消费则立即暴露重复清理。
"""
raise AssertionError("已完成的 provider 前 cleanup 不应在宿主重放时重复执行")
result = FileManagerModule().execute_transfer_plan(
persisted_checkpoint,
meta=meta,
mediainfo=mediainfo,
source_oper=transfer_storage,
target_oper=transfer_storage,
cleanup_media_file=unexpected_cleanup,
)
assert result.success is True
assert ChainEventType.TransferIntercept.value in calls
assert any(call.startswith("copy:") for call in calls)
def test_cleanup_failure_keeps_the_new_target_and_raises_for_retry(monkeypatch):
"""
旧目的清理失败可重试,已成功落地的新目标不能因此被删除。
"""
calls: list[str] = []
checkpoint, meta, mediainfo, cleanup_item = _build_cleanup_checkpoint(
monkeypatch,
calls,
)
transfer_storage = RecordingStorage(calls)
module = FileManagerModule()
def cleanup_media_file(fileitem: FileItem) -> bool:
"""
模拟统一删除能力执行保护治理后的失败结果。
"""
calls.append("cleanup_compat_failed")
return False
with pytest.raises(RuntimeError, match="整理计划保留待重试"):
module.execute_transfer_plan(
checkpoint,
meta=meta,
mediainfo=mediainfo,
source_oper=transfer_storage,
target_oper=transfer_storage,
cleanup_media_file=cleanup_media_file,
)
assert ChainEventType.TransferIntercept.value in calls
assert "cleanup_compat_failed" in calls
assert "get_folder" in calls
assert any(call.startswith("copy:") for call in calls)
assert checkpoint.final_target_path in transfer_storage.files
def test_cleanup_is_not_called_when_materialization_fails(monkeypatch):
"""
新目标未落地时保留历史旧目的,不调用任何兼容删除能力。
"""
calls = []
checkpoint, meta, mediainfo, _ = _build_cleanup_checkpoint(monkeypatch, calls)
storage = RecordingStorage(calls)
monkeypatch.setattr(storage, "copy", lambda *_args: False)
cleanup = Mock(return_value=True)
result = FileManagerModule().execute_transfer_plan(
checkpoint, meta=meta, mediainfo=mediainfo,
source_oper=storage, target_oper=storage, cleanup_media_file=cleanup,
)
assert result.success is False
cleanup.assert_not_called()
@pytest.mark.parametrize("ancestor", [False, True])
def test_cleanup_never_removes_the_new_target_or_its_parent(monkeypatch, ancestor):
"""
同路径或祖先目录的历史目的交文件级覆盖,不能整块删除新版本。
"""
calls = []
checkpoint, meta, mediainfo, _ = _build_cleanup_checkpoint(monkeypatch, calls)
path = Path(checkpoint.final_target_path)
cleanup = FileItem(storage="alist", path=(path.parent if ancestor else path).as_posix(),
type="dir" if ancestor else "file")
planning_input = replace(checkpoint.planning_input, options={
"cleanup_dest_fileitem": cleanup.model_dump(mode="json"),
})
checkpoint = replace(checkpoint, planning_input=planning_input)
storage = RecordingStorage(calls, existing=FileItem(
storage="alist", path=path.as_posix(), name=path.name, type="file", size=100, modify_time=1.0,
))
unexpected_cleanup = Mock(side_effect=AssertionError("不可删除重合目的"))
result = FileManagerModule().execute_transfer_plan(
checkpoint, meta=meta, mediainfo=mediainfo,
source_oper=storage, target_oper=storage, cleanup_media_file=unexpected_cleanup,
)
assert result.success
assert path.as_posix() in storage.files
unexpected_cleanup.assert_not_called()
def test_empty_directory_plan_is_zero_operation_without_intercept_or_cleanup(
monkeypatch,
):
"""
空目录生成零操作计划,执行时不触发拦截或清理。
"""
calls: list[str] = []
root = FileItem(
storage="alist",
path="/downloads/empty",
name="empty",
type="dir",
)
cleanup_item = FileItem(
storage="alist",
path="/library/old.mkv",
name="old.mkv",
type="file",
)
planning_input = _build_input(
root,
need_rename=False,
options={"cleanup_dest_fileitem": cleanup_item.model_dump(mode="json")},
)
meta, mediainfo = _build_media()
checkpoint = TransHandler().plan_transfer(
planning_input,
meta=meta,
mediainfo=mediainfo,
source_oper=ReadOnlyTreeStorage({root.path: []}),
target_storage="alist",
target_path=Path("/library"),
transfer_type="copy",
need_scrape=False,
need_rename=False,
need_notify=True,
overwrite_mode="never",
episodes_info=None,
preview=False,
)
assert checkpoint.items == ()
def unexpected_event(event_type, event_data):
"""
空计划若触发事件则立即失败。
"""
raise AssertionError(f"空计划不应触发事件:{event_type}")
monkeypatch.setattr(eventmanager, "send_event", unexpected_event)
result = FileManagerModule().execute_transfer_plan(
checkpoint,
meta=meta,
mediainfo=mediainfo,
source_oper=RecordingStorage(calls),
target_oper=RecordingStorage(calls),
cleanup_media_file=lambda _fileitem: calls.append("cleanup") or True,
)
assert result.success is True
assert calls == []
def test_directory_intercept_preserves_legacy_payload(monkeypatch):
"""
目录拦截事件保留旧载荷约定,不额外声明元数据和选项字段。
"""
calls: list[str] = []
root = FileItem(
storage="alist",
path="/downloads/disc",
name="disc",
type="dir",
)
child = FileItem(
storage="alist",
path="/downloads/disc/index.bdmv",
name="index.bdmv",
type="file",
extension="bdmv",
)
meta, mediainfo = _build_media()
checkpoint = TransHandler().plan_transfer(
_build_input(root, need_rename=False),
meta=meta,
mediainfo=mediainfo,
source_oper=ReadOnlyTreeStorage({root.path: [child]}),
target_storage="alist",
target_path=Path("/library"),
transfer_type="copy",
need_scrape=False,
need_rename=False,
need_notify=True,
overwrite_mode="never",
episodes_info=None,
preview=False,
)
intercept_payloads = []
def capture_event(event_type, event_data):
"""
捕获目录根拦截事件。
"""
if event_type == ChainEventType.TransferIntercept:
intercept_payloads.append(event_data)
return None
monkeypatch.setattr(eventmanager, "send_event", capture_event)
result = TransHandler().execute_transfer_plan(
checkpoint,
meta=meta,
mediainfo=mediainfo,
source_oper=RecordingStorage(calls),
target_oper=RecordingStorage(calls),
)
assert result.success is True
assert len(intercept_payloads) == 1
payload = intercept_payloads[0]
assert payload.meta is None
assert payload.options is None
assert "meta" not in payload.model_fields_set
assert "options" not in payload.model_fields_set
def test_directory_intercept_step_uses_frozen_source_after_size_projection():
"""
目录执行期修正根目录大小时,持久拦截意图仍使用冻结源快照。
"""
root = FileItem(
storage="alist",
path="/downloads/disc",
name="disc",
type="dir",
)
stream = FileItem(
storage="alist",
path="/downloads/disc/BDMV/STREAM/00001.m2ts",
name="00001.m2ts",
type="file",
extension="m2ts",
size=123,
)
planning_input = TransferPlanningInput(
source_fileitem=root.model_dump(mode="json"),
target_storage="alist",
target_path="/library",
requested_transfer_type="copy",
need_rename=False,
)
checkpoint = TransferPlanCheckpoint(
planning_input=planning_input,
target_storage="alist",
root_target_path="/library/disc",
final_target_path="/library/disc",
resolved_transfer_type="copy",
items=(TransferPlanItem(
sequence=0,
source_fileitem=stream.model_dump(mode="json"),
target_storage="alist",
target_path="/library/disc/BDMV/STREAM/00001.m2ts",
),),
need_rename=False,
overwrite_mode="never",
)
runner = Mock()
steps = []
def run_step(*, phase, kind, payload, execute, observe):
"""
模拟 durable runner,并记录其接受的冻结步骤意图。
"""
steps.append((phase, kind, payload))
return execute()
runner.run.side_effect = run_step
storage = RecordingStorage([])
result = TransHandler().execute_transfer_plan(
checkpoint,
meta=_build_media()[0],
mediainfo=_build_media()[1],
source_oper=storage,
target_oper=storage,
step_runner=runner,
)
assert result.success is True
intercept = next(
payload
for _phase, kind, payload in steps
if kind == "plugin_transfer_intercept"
)
assert intercept["source"] == planning_input.source_fileitem