Files
MoviePilot/tests/test_workflow_actions.py
Aqr-K bf4ef4214e perf(mediaserver): add caller-controlled query projections (#7068)
* perf(mediaserver): add caller-controlled query projections

* test(mediaserver): cover query projection option contracts

---------

Co-authored-by: jxxghp <jxxghp@gmail.com>
2026-10-19 08:33:45 +08:00

441 lines
16 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 types import SimpleNamespace
from app.domain.context import Context, TorrentInfo
from app.domain.metainfo import MetaInfo
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 [
Context(
meta_info=MetaInfo("Test S00"),
torrent_info=TorrentInfo(title="Test S00"),
),
Context(
meta_info=MetaInfo("Test S01"),
torrent_info=TorrentInfo(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"] == "资源"
def test_add_download_only_lack_handles_schema_metainfo(monkeypatch):
"""
仅下载缺失资源时应兼容工作流 MetaInfo 并向下载链传递领域上下文。
"""
import app.workflow.actions.add_download as add_download_module
from app.domain.context import MediaInfo as DomainMediaInfo
from app.domain.meta.metabase import MetaBase
from app.schemas.context import (
Context as SchemaContext,
)
from app.schemas.context import (
MediaInfo as SchemaMediaInfo,
)
from app.schemas.context import (
MetaInfo as SchemaMetaInfo,
)
from app.schemas.context import (
TorrentInfo as SchemaTorrentInfo,
)
from app.schemas.mediaserver import MediaServerField
from app.schemas.types import MediaType
from app.workflow.actions.add_download import AddDownloadAction
downloaded = []
class FakeDownloadChain:
"""
模拟媒体库检查和下载提交,并验证工作流对象已转换为领域类型。
"""
def media_exists(self, mediainfo, *, query):
"""
返回已存在剧集,并检查传入媒体使用领域类型。
"""
assert isinstance(mediainfo, DomainMediaInfo)
assert mediainfo.type is MediaType.TV
assert query.fields == {MediaServerField.IDENTITY, MediaServerField.YEAR}
# 模拟媒体库已存在第 1 季的第 1、2 集
return SimpleNamespace(seasons={1: [1, 2]})
def download_single(self, context=None, **kwargs):
"""
记录提交的种子名称,并验证下载链拿到领域上下文。
"""
assert isinstance(context, Context)
assert isinstance(context.media_info, DomainMediaInfo)
assert isinstance(context.meta_info, MetaBase)
assert isinstance(context.torrent_info, TorrentInfo)
downloaded.append(context.torrent_info.title)
return f"hash-{context.torrent_info.title}"
monkeypatch.setattr(add_download_module, "DownloadChain", FakeDownloadChain)
monkeypatch.setattr(add_download_module.runtime_stop_state, "is_workflow_stopped", lambda _wid: False)
action = AddDownloadAction("test-add-download")
action.check_cache = lambda _wid, _key: False
action.save_cache = lambda _wid, _key: None
action.job_done = lambda *_args, **_kwargs: None
# 1. 第 1 季第 1 集(已存在,跳过)
t1 = SchemaContext(
meta_info=SchemaMetaInfo(title="Show S01E01", type="电视剧", begin_season=1, episode_list=[1]),
media_info=SchemaMediaInfo(title="Show", type=MediaType.TV),
torrent_info=SchemaTorrentInfo(title="Show S01E01", site=1),
)
# 2. 第 1 季第 3 集(缺失,应下载)
t2 = SchemaContext(
meta_info=SchemaMetaInfo(title="Show S01E03", type="电视剧", begin_season=1, episode_list=[3]),
media_info=SchemaMediaInfo(title="Show", type=MediaType.TV),
torrent_info=SchemaTorrentInfo(title="Show S01E03", site=1),
)
# 3. 多季资源(有多季,跳过)
t3 = SchemaContext(
meta_info=SchemaMetaInfo(title="Show S01-S02", type="电视剧", begin_season=1, end_season=2, episode_list=[1]),
media_info=SchemaMediaInfo(title="Show", type=MediaType.TV),
torrent_info=SchemaTorrentInfo(title="Show S01-S02", site=1),
)
# 4. 未显式标季但为电视剧(begin_season=None,默认归入第 1 季;第 1 集已存在,跳过)
t4 = SchemaContext(
meta_info=SchemaMetaInfo(title="Show Ep01", type="电视剧", begin_season=None, episode_list=[1]),
media_info=SchemaMediaInfo(title="Show", type=MediaType.TV),
torrent_info=SchemaTorrentInfo(title="Show Ep01", site=1),
)
# 5. 未显式标季但为电视剧(begin_season=None,默认归入第 1 季;第 4 集缺失,应下载)
t5 = SchemaContext(
meta_info=SchemaMetaInfo(title="Show Ep04", type="电视剧", begin_season=None, episode_list=[4]),
media_info=SchemaMediaInfo(title="Show", type=MediaType.TV),
torrent_info=SchemaTorrentInfo(title="Show Ep04", site=1),
)
context = ActionContext(torrents=[t1, t2, t3, t4, t5])
result = action.execute(workflow_id=1, params={"only_lack": True}, context=context)
assert downloaded == ["Show S01E03", "Show Ep04"]
assert len(result.downloads) == 2
assert [d.download_id for d in result.downloads] == ["hash-Show S01E03", "hash-Show Ep04"]
def test_add_download_converts_workflow_music_context_to_domain_types():
"""
添加下载动作应将音乐工作流模型恢复为下载链使用的领域对象。
"""
from app.domain.context import MusicInfo as DomainMusicInfo
from app.domain.meta.metamusic import MetaMusic
from app.schemas.context import Context as SchemaContext
from app.schemas.context import TorrentInfo as SchemaTorrentInfo
from app.schemas.music import MusicInfo as SchemaMusicInfo
from app.schemas.music import MusicMeta
from app.schemas.types import MediaType
from app.workflow.actions.add_download import AddDownloadAction
context = SchemaContext(
meta_info=MusicMeta(title="Track", artists=["Artist"]),
media_info=SchemaMusicInfo(title="Track", artists=["Artist"]),
torrent_info=SchemaTorrentInfo(title="Track", site=1),
)
result = AddDownloadAction._to_domain_context(context)
assert isinstance(result, Context)
assert isinstance(result.meta_info, MetaMusic)
assert isinstance(result.media_info, DomainMusicInfo)
assert result.media_info.type is MediaType.MUSIC
assert isinstance(result.torrent_info, TorrentInfo)