mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-11 16:21:09 +08:00
337 lines
13 KiB
Python
337 lines
13 KiB
Python
"""Agent 数据上下文与类型化任务适配器测试。"""
|
|
|
|
from contextlib import asynccontextmanager
|
|
from dataclasses import FrozenInstanceError, replace
|
|
from types import SimpleNamespace
|
|
from typing import cast
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
import app.agent.tools.factory as tool_factory_module
|
|
import app.application.agent as agent_services
|
|
from app.agent.memory import MemoryManager
|
|
from app.agent.tools.factory import MoviePilotToolFactory
|
|
from app.agent.tools.impl.delete_rule_group import DeleteRuleGroupTool
|
|
from app.agent.tools.impl.query_agent_tasks import QueryAgentTasksTool
|
|
from app.agent.tools.impl.update_custom_filter_rule import UpdateCustomFilterRuleTool
|
|
from app.agent.tools.manager import MoviePilotToolsManager
|
|
from app.application.agent import AgentDataContext
|
|
from app.application.agenttask import AgentTaskRepository, AgentTaskSnapshot
|
|
from app.db.adapters.agent import TransactionalAgentTaskRepository
|
|
from app.db.session import SessionFactory
|
|
from app.scheduler import Scheduler
|
|
from app.schemas.rule import CustomRule
|
|
from app.schemas.system import FilterRuleGroup
|
|
from app.startup.initializers import agent as agent_initializer
|
|
|
|
|
|
def _context(tasks: AgentTaskRepository) -> AgentDataContext:
|
|
"""构造仅用于验证依赖身份的 Agent 数据上下文。"""
|
|
dependency = cast(object, SimpleNamespace())
|
|
return AgentDataContext(
|
|
chat=cast(object, dependency),
|
|
chat_persistence=cast(object, dependency),
|
|
tasks=tasks,
|
|
users=cast(object, dependency),
|
|
sites=cast(object, dependency),
|
|
subscriptions=cast(object, dependency),
|
|
subscription_mutation_scope=cast(object, dependency),
|
|
subscription_delete_scope=cast(object, dependency),
|
|
async_rule_group_mutation_scope=cast(object, dependency),
|
|
subscription_history=cast(object, dependency),
|
|
transfer_history=cast(object, dependency),
|
|
transfer_execution=cast(object, dependency),
|
|
download_history=cast(object, dependency),
|
|
plugin_data=cast(object, dependency),
|
|
)
|
|
|
|
|
|
def test_agent_tool_receives_exact_data_context() -> None:
|
|
"""内置工具只能消费构造时注入的同一上下文,不再查询全局注册表。"""
|
|
repository = TransactionalAgentTaskRepository(SessionFactory)
|
|
context = _context(repository)
|
|
tool = QueryAgentTasksTool(session_id="session", user_id="user", data=context)
|
|
|
|
assert tool.data is context
|
|
assert tool.data.tasks is repository
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_rule_group_uses_injected_atomic_mutation_scope(monkeypatch) -> None:
|
|
"""Agent 删除规则组必须一次提交定义和全部引用并在释放作用域后广播。"""
|
|
mutation = MagicMock()
|
|
mutation.apply = AsyncMock(
|
|
return_value=SimpleNamespace(to_dict=lambda: {"subscribes": []})
|
|
)
|
|
|
|
@asynccontextmanager
|
|
async def mutation_scope():
|
|
"""提供可观测的异步规则组事务作用域。"""
|
|
yield mutation
|
|
|
|
context = replace(
|
|
_context(TransactionalAgentTaskRepository(SessionFactory)),
|
|
async_rule_group_mutation_scope=mutation_scope,
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.agent.tools.impl.delete_rule_group.get_rule_groups",
|
|
lambda: [FilterRuleGroup(name="old", rule_string="4K")],
|
|
)
|
|
publish = AsyncMock()
|
|
monkeypatch.setattr(
|
|
"app.agent.tools.impl.delete_rule_group.publish_rule_config_changed",
|
|
publish,
|
|
)
|
|
tool = DeleteRuleGroupTool(session_id="session", user_id="user", data=context)
|
|
|
|
result = await tool.run(name="old")
|
|
|
|
assert '"success": true' in result
|
|
mutation.apply.assert_awaited_once_with(
|
|
[],
|
|
expected_rule_groups=[{"name": "old", "rule_string": "4K"}],
|
|
previous_name="old",
|
|
)
|
|
publish.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_custom_rule_rename_commits_rule_and_group_definitions_together(
|
|
monkeypatch,
|
|
) -> None:
|
|
"""自定义规则改名必须用一个事务同时提交规则本体和规则组表达式。"""
|
|
mutation = MagicMock()
|
|
mutation.apply = AsyncMock(return_value=SimpleNamespace())
|
|
|
|
@asynccontextmanager
|
|
async def mutation_scope():
|
|
"""提供可观测的异步组合事务作用域。"""
|
|
yield mutation
|
|
|
|
context = replace(
|
|
_context(TransactionalAgentTaskRepository(SessionFactory)),
|
|
async_rule_group_mutation_scope=mutation_scope,
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.agent.tools.impl.update_custom_filter_rule.get_custom_rules",
|
|
lambda: [CustomRule(id="OLD", name="旧规则", include="old")],
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.agent.tools.impl.update_custom_filter_rule.get_rule_groups",
|
|
lambda: [FilterRuleGroup(name="group", rule_string="OLD & 4K")],
|
|
)
|
|
publish = AsyncMock()
|
|
save = AsyncMock()
|
|
monkeypatch.setattr(
|
|
"app.agent.tools.impl.update_custom_filter_rule.publish_rule_config_changed",
|
|
publish,
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.agent.tools.impl.update_custom_filter_rule.save_system_config",
|
|
save,
|
|
)
|
|
tool = UpdateCustomFilterRuleTool(
|
|
session_id="session",
|
|
user_id="user",
|
|
data=context,
|
|
)
|
|
|
|
result = await tool.run(current_rule_id="OLD", new_rule_id="NEW")
|
|
|
|
assert '"success": true' in result
|
|
mutation.apply.assert_awaited_once_with(
|
|
[{"name": "group", "rule_string": "NEW & 4K"}],
|
|
expected_rule_groups=[{"name": "group", "rule_string": "OLD & 4K"}],
|
|
custom_rules=[{"id": "NEW", "name": "旧规则", "include": "old"}],
|
|
expected_custom_rules=[{"id": "OLD", "name": "旧规则", "include": "old"}],
|
|
)
|
|
save.assert_not_awaited()
|
|
assert publish.await_count == 2
|
|
|
|
|
|
def test_agent_service_facade_resolves_registered_dependencies(monkeypatch) -> None:
|
|
"""Agent 服务门面应稳定处理未装配状态并转发组合根注入能力。"""
|
|
provider_names = (
|
|
"_agent_manager_provider",
|
|
"_running_agent_manager_provider",
|
|
"_prompt_manager_provider",
|
|
"_agent_capability_manager_provider",
|
|
"_llm_helper_provider",
|
|
"_manual_redo_prompt_builder_provider",
|
|
)
|
|
for provider_name in provider_names:
|
|
monkeypatch.setattr(agent_services, provider_name, None)
|
|
|
|
assert agent_services.get_running_agent_manager() is None
|
|
with pytest.raises(RuntimeError, match="agent_manager 未注册"):
|
|
agent_services.get_agent_manager()
|
|
|
|
manager = object()
|
|
prompt_manager = object()
|
|
capability_manager = MagicMock()
|
|
capability_manager.is_audio_input_available.return_value = True
|
|
capability_manager.transcribe_audio.return_value = "转写结果"
|
|
llm_helper = MagicMock()
|
|
llm_helper.supports_image_input.return_value = True
|
|
agent_services.register_agent_services(
|
|
agent_manager=manager,
|
|
prompt_manager=prompt_manager,
|
|
capability_manager=capability_manager,
|
|
llm_helper=llm_helper,
|
|
)
|
|
|
|
assert agent_services.get_agent_manager() is manager
|
|
assert agent_services.get_running_agent_manager() is manager
|
|
assert agent_services.get_prompt_manager() is prompt_manager
|
|
assert agent_services.supports_image_input(
|
|
provider="openai",
|
|
model="vision-model",
|
|
base_url="https://example.com",
|
|
base_url_preset="custom",
|
|
)
|
|
llm_helper.supports_image_input.assert_called_once_with(
|
|
provider="openai",
|
|
model="vision-model",
|
|
base_url="https://example.com",
|
|
base_url_preset="custom",
|
|
)
|
|
assert agent_services.is_audio_input_available()
|
|
assert agent_services.transcribe_audio(b"audio", filename="voice.wav") == "转写结果"
|
|
capability_manager.transcribe_audio.assert_called_once_with(
|
|
b"audio",
|
|
filename="voice.wav",
|
|
)
|
|
with pytest.raises(RuntimeError, match="提示词构建器未注册"):
|
|
agent_services.build_manual_redo_prompt(object())
|
|
|
|
agent_services.register_agent_services(
|
|
agent_manager=manager,
|
|
prompt_manager=prompt_manager,
|
|
capability_manager=capability_manager,
|
|
llm_helper=llm_helper,
|
|
manual_redo_prompt_builder=lambda history: f"redo:{history}",
|
|
)
|
|
assert agent_services.build_manual_redo_prompt("history") == "redo:history"
|
|
|
|
|
|
def test_tool_factory_injects_context_only_into_builtin_tools(monkeypatch) -> None:
|
|
"""工具工厂必须把上下文传给宿主工具,同时保持插件构造 ABI 不变。"""
|
|
repository = TransactionalAgentTaskRepository(SessionFactory)
|
|
context = _context(repository)
|
|
monkeypatch.setattr(
|
|
MoviePilotToolFactory,
|
|
"_get_builtin_tool_classes",
|
|
classmethod(lambda _cls, _channel=None: [QueryAgentTasksTool]),
|
|
)
|
|
monkeypatch.setattr(tool_factory_module, "_get_plugin_agent_tools", lambda: [])
|
|
|
|
tools = MoviePilotToolFactory.create_tools(
|
|
session_id="session",
|
|
user_id="user",
|
|
data=context,
|
|
)
|
|
|
|
assert len(tools) == 1
|
|
assert tools[0].data is context
|
|
|
|
|
|
def test_tool_manager_reassembly_invalidates_factory_catalog() -> None:
|
|
"""重复装配数据上下文时必须丢弃持有旧仓储的工厂工具快照。"""
|
|
first = _context(TransactionalAgentTaskRepository(SessionFactory))
|
|
second = _context(TransactionalAgentTaskRepository(SessionFactory))
|
|
manager = MoviePilotToolsManager(data=first)
|
|
manager.tools = [
|
|
QueryAgentTasksTool(session_id="session", user_id="user", data=first)
|
|
]
|
|
manager._catalog_materialized = True
|
|
manager._catalog_managed_by_factory = True
|
|
|
|
manager.set_data_context(second)
|
|
|
|
assert manager._data is second
|
|
assert manager.tools == []
|
|
assert manager.catalog is None
|
|
assert manager._catalog_materialized is False
|
|
|
|
|
|
def test_memory_manager_reads_only_injected_chat_service() -> None:
|
|
"""记忆恢复必须调用构造时注入的会话服务,不再查询数据 locator。"""
|
|
chat = MagicMock()
|
|
chat.get_sync.return_value = SimpleNamespace(agent_messages=[])
|
|
manager = MemoryManager(chat=chat, persistence=MagicMock())
|
|
|
|
assert manager.get_agent_messages("session", "user") == []
|
|
chat.get_sync.assert_called_once_with(session_id="session", user_id="user")
|
|
|
|
|
|
def test_scheduler_reads_injected_agent_task_repository() -> None:
|
|
"""Scheduler 注册任务时必须读取显式注入的 AgentTask 仓储。"""
|
|
repository = MagicMock()
|
|
repository.list.return_value = [SimpleNamespace(id=9)]
|
|
scheduler = object.__new__(Scheduler)
|
|
Scheduler.__init__(scheduler)
|
|
scheduler.update_agent_task_job = MagicMock()
|
|
scheduler.configure_agent_tasks(repository)
|
|
|
|
scheduler.init_agent_task_jobs()
|
|
|
|
repository.list.assert_called_once_with(enabled=True)
|
|
scheduler.update_agent_task_job.assert_called_once_with(9)
|
|
|
|
|
|
def test_agent_initializer_reassembly_replaces_cached_manager(monkeypatch) -> None:
|
|
"""组合根重装配后不得复用持有旧数据上下文的 manager。"""
|
|
first = _context(TransactionalAgentTaskRepository(SessionFactory))
|
|
second = _context(TransactionalAgentTaskRepository(SessionFactory))
|
|
monkeypatch.setattr(agent_initializer, "_agent_data_context", None)
|
|
monkeypatch.setattr(agent_initializer, "_injected_agent_manager", None)
|
|
monkeypatch.setattr(
|
|
"app.agent.orchestrator.memory_manager",
|
|
MemoryManager(),
|
|
)
|
|
|
|
agent_initializer.configure_agent_data_context(first)
|
|
first_manager = agent_initializer._get_injected_agent_manager()
|
|
agent_initializer.configure_agent_data_context(second)
|
|
second_manager = agent_initializer._get_injected_agent_manager()
|
|
|
|
assert first_manager is not second_manager
|
|
assert first_manager._data is first
|
|
assert second_manager._data is second
|
|
|
|
|
|
def test_transactional_agent_task_repository_projects_frozen_snapshots() -> None:
|
|
"""任务写入提交后必须返回脱离 Session 的冻结快照。"""
|
|
repository = TransactionalAgentTaskRepository(SessionFactory)
|
|
task = repository.add(
|
|
name="架构检查",
|
|
content="检查 Agent 数据边界",
|
|
trigger_type="cron",
|
|
cron_expression="0 8 * * *",
|
|
run_at=None,
|
|
user_id="user-1",
|
|
username="tester",
|
|
session_id="session-1",
|
|
channel=None,
|
|
source="test",
|
|
original_chat_id=None,
|
|
)
|
|
|
|
assert isinstance(task, AgentTaskSnapshot)
|
|
assert repository.get(task.id, user_id="user-1") == task
|
|
assert repository.list(user_id="user-1") == [task]
|
|
with pytest.raises(FrozenInstanceError):
|
|
task.name = "不可修改" # type: ignore[misc]
|
|
|
|
assert repository.update(
|
|
task.id,
|
|
{"name": "已更新"},
|
|
user_id="user-1",
|
|
)
|
|
updated = repository.get(task.id, user_id="user-1")
|
|
assert updated is not None
|
|
assert updated.name == "已更新"
|
|
assert repository.delete(task.id, user_id="user-1")
|
|
assert repository.get(task.id, user_id="user-1") is None
|