Files
MoviePilot/tests/test_workflow_actions.py
T

252 lines
8.8 KiB
Python

from types import SimpleNamespace
from app.schemas.download import DownloadTask
from app.schemas.file import FileItem
from app.schemas.workflow import ActionContext, ActionResult
from app.workflow import WorkFlowManager
from app.workflow.actions import BaseAction
from app.workflow.actions import fetch_downloads as fetch_downloads_module
from app.workflow.actions import fetch_torrents as fetch_torrents_module
from app.workflow.actions import scrape_file as scrape_file_module
from app.workflow.actions.fetch_downloads import FetchDownloadsAction
from app.workflow.actions.fetch_rss import FetchRssAction
from app.workflow.actions.fetch_torrents import FetchTorrentsAction
from app.workflow.actions.scrape_file import ScrapeFileAction
def test_fetch_downloads_updates_context_downloads(monkeypatch):
"""获取下载任务动作应更新上游上下文中的下载任务。"""
calls = []
class FakeActionChain:
"""模拟下载器查询链。"""
def list_torrents(self, hashs=None, downloader=None, **kwargs):
calls.append((hashs, downloader))
return [SimpleNamespace(path="/downloads/movie.mkv", progress=100)]
monkeypatch.setattr(fetch_downloads_module, "ActionChain", FakeActionChain)
monkeypatch.setattr(fetch_downloads_module.runtime_stop_state, "is_workflow_stopped", lambda workflow_id: False)
context = ActionContext(
downloads=[
DownloadTask(download_id="hash-1", downloader="qbittorrent"),
]
)
result = FetchDownloadsAction("fetch-downloads").execute(
workflow_id=1,
params={},
context=context,
)
assert calls == [(["hash-1"], "qbittorrent")]
assert result.downloads[0].completed is True
assert result.downloads[0].path == "/downloads/movie.mkv"
def test_fetch_torrents_filters_special_season_zero(monkeypatch):
"""工作流显式选择季 0 时只能保留特别季资源。"""
class FakeSearchChain:
"""返回特别季和第一季候选,验证动作层季过滤。"""
def search_by_title(self, **_kwargs):
return [
SimpleNamespace(
meta_info=SimpleNamespace(year=None, begin_season=0),
media_info=None,
torrent_info=SimpleNamespace(title="Test S00"),
),
SimpleNamespace(
meta_info=SimpleNamespace(year=None, begin_season=1),
media_info=None,
torrent_info=SimpleNamespace(title="Test S01"),
),
]
monkeypatch.setattr(fetch_torrents_module, "SearchChain", FakeSearchChain)
monkeypatch.setattr(fetch_torrents_module.runtime_stop_state, "is_workflow_stopped", lambda _workflow_id: False)
action = FetchTorrentsAction("fetch-torrents")
action.job_done = lambda *_args, **_kwargs: None
result = action.execute(
workflow_id=1,
params={"search_type": "keyword", "name": "Test", "season": 0},
context=ActionContext(),
)
assert [item.meta_info.begin_season for item in result.torrents] == [0]
def test_scrape_file_keeps_workflow_action_context(monkeypatch):
"""刮削文件动作不应将工作流上下文替换为媒体识别上下文。"""
scraped = []
class FakeStorageChain:
"""模拟存储链。"""
def exists(self, fileitem):
return True
class FakeMediaChain:
"""模拟媒体识别链。"""
def recognize_by_path(self, path, obtain_images=False):
return SimpleNamespace(meta_info="meta", media_info="media")
class FakeScrapingChain:
"""模拟独立刮削链。"""
def scrape_metadata(self, fileitem, meta=None, mediainfo=None):
scraped.append((fileitem.path, meta, mediainfo))
monkeypatch.setattr(scrape_file_module, "StorageChain", FakeStorageChain)
monkeypatch.setattr(scrape_file_module, "MediaChain", FakeMediaChain)
monkeypatch.setattr(scrape_file_module, "ScrapingChain", FakeScrapingChain)
monkeypatch.setattr(scrape_file_module.runtime_stop_state, "is_workflow_stopped", lambda workflow_id: False)
monkeypatch.setattr(ScrapeFileAction, "check_cache", lambda self, workflow_id, key: False)
monkeypatch.setattr(ScrapeFileAction, "save_cache", lambda self, workflow_id, data: None)
context = ActionContext(
fileitems=[
FileItem(path="/library/movie.mkv", storage="local", type="file"),
]
)
result = ScrapeFileAction("scrape-file").execute(
workflow_id=1,
params={},
context=context,
)
assert result is context
assert result.fileitems[0].path == "/library/movie.mkv"
assert scraped == [("/library/movie.mkv", "meta", "media")]
def test_scrape_file_does_not_cache_failed_music_scrape(monkeypatch):
"""音乐刮削失败时工作流必须计为失败,不能写入成功缓存。"""
saved_cache = []
class FakeStorageChain:
"""模拟存在的音乐文件。"""
def exists(self, fileitem):
return True
class FakeMediaChain:
"""模拟媒体识别成功。"""
def recognize_by_path(self, path, obtain_images=False):
return SimpleNamespace(meta_info="music-meta", media_info="music")
class FakeScrapingChain:
"""模拟音乐产物写入失败。"""
def scrape_metadata(self, fileitem, meta=None, mediainfo=None):
return False, "歌词保存失败"
monkeypatch.setattr(scrape_file_module, "StorageChain", FakeStorageChain)
monkeypatch.setattr(scrape_file_module, "MediaChain", FakeMediaChain)
monkeypatch.setattr(scrape_file_module, "ScrapingChain", FakeScrapingChain)
monkeypatch.setattr(
scrape_file_module.runtime_stop_state,
"is_workflow_stopped",
lambda workflow_id: False,
)
monkeypatch.setattr(
ScrapeFileAction,
"check_cache",
lambda self, workflow_id, key: False,
)
monkeypatch.setattr(
ScrapeFileAction,
"save_cache",
lambda self, workflow_id, data: saved_cache.append(data),
)
action = ScrapeFileAction("scrape-music")
action.execute(
workflow_id=1,
params={},
context=ActionContext(
fileitems=[
FileItem(path="/library/晴天.flac", storage="local", type="file")
]
),
)
assert action.success is False
assert action._scraped_files == []
assert saved_cache == []
def test_execute_with_inputs_maps_contract_inputs_outputs_and_runtime(monkeypatch):
"""新版动作桥接方法应按契约映射输入、输出和运行期信息。"""
class ContractAction(BaseAction):
"""测试动作契约桥接。"""
contract = {
"inputs": [{"name": "torrents", "label": "资源", "kind": "list"}],
"outputs": [{"name": "downloads", "label": "下载任务", "kind": "list"}],
}
name = "契约动作"
description = "测试契约动作"
data = {}
@property
def success(self) -> bool:
return True
def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:
"""执行测试动作。"""
_ = workflow_id, params
context.downloads = [
DownloadTask(download_id=f"{item}-hash", downloader="qbittorrent")
for item in context.torrents
]
self.job_done("完成")
return context
result = ContractAction("contract").execute_with_inputs(
workflow_id=1,
params={},
inputs={"torrents": ["movie"]},
runtime={"attempt": 1, "max_attempts": 1, "cancel_token": object()},
context=ActionContext(),
)
assert isinstance(result, ActionResult)
assert result.outputs["downloads"][0].download_id == "movie-hash"
assert result.context.runtime_state["current_action_runtime"] == {
"attempt": 1,
"max_attempts": 1,
}
path_result = ContractAction("contract").execute_with_inputs(
workflow_id=1,
params={},
inputs={"outputs.FetchRssAction.torrents": ["legacy"]},
runtime={},
context=ActionContext(),
)
assert path_result.outputs["downloads"][0].download_id == "legacy-hash"
def test_workflow_manager_list_actions_exposes_contract():
"""动作列表应返回固定输入输出契约。"""
manager = object.__new__(WorkFlowManager)
manager._actions = {"FetchRssAction": FetchRssAction}
actions = manager.list_actions()
assert actions[0]["name"] == "获取RSS资源"
assert actions[0]["description"] == "订阅RSS地址获取资源"
assert isinstance(actions[0]["data"], dict)
assert actions[0]["contract"]["outputs"][0]["name"] == "torrents"
assert actions[0]["contract"]["condition_fields"][0]["label"] == "资源"