mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
refactor backend module architecture
This commit is contained in:
+4
-4
@@ -6,7 +6,7 @@
|
||||
import sys
|
||||
|
||||
# 必须早于首个 import app.db(其在 import 期即按 CONFIG_PATH 连库):prepare_backend 内部
|
||||
# 先隔离 CONFIG_DIR、补 app.helper.sites 垫片,再建表。app/testing 仅依赖标准库、import 不连库,
|
||||
# 先隔离 CONFIG_DIR、补 app.infrastructure.sites 垫片,再建表。app/testing 仅依赖标准库、import 不连库,
|
||||
# 故此处先 import 再调用是安全的。
|
||||
from app.testing.bootstrap import prepare_backend
|
||||
|
||||
@@ -33,7 +33,7 @@ def pytest_sessionfinish(session, exitstatus):
|
||||
_report_session_cleanup_error(session, "agent blocking executors", err)
|
||||
|
||||
try:
|
||||
from app.helper.thread import ThreadHelper
|
||||
from app.platform.thread import ThreadHelper
|
||||
|
||||
helper = ThreadHelper.get_existing_instance()
|
||||
if helper:
|
||||
@@ -42,14 +42,14 @@ def pytest_sessionfinish(session, exitstatus):
|
||||
_report_session_cleanup_error(session, "thread helper", err)
|
||||
|
||||
try:
|
||||
from app.helper.message import stop_message
|
||||
from app.messaging.message import stop_message
|
||||
|
||||
stop_message()
|
||||
except Exception as err:
|
||||
_report_session_cleanup_error(session, "message service", err)
|
||||
|
||||
try:
|
||||
from app.log import LoggerManager
|
||||
from app.platform.log import LoggerManager
|
||||
|
||||
LoggerManager.shutdown()
|
||||
except Exception as err:
|
||||
|
||||
@@ -4,9 +4,9 @@ from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
from app.core.config import ConfigModel
|
||||
from app.platform.config import ConfigModel
|
||||
from app.modules.acoustid import AcoustIdModule
|
||||
from app.utils.http import AsyncRequestUtils, RequestUtils
|
||||
from app.foundation.http import AsyncRequestUtils, RequestUtils
|
||||
|
||||
|
||||
RECORDING_ID = "38035858-f990-4fbb-b3b2-f2f8b958eeba"
|
||||
|
||||
@@ -20,8 +20,8 @@ from app.agent.middleware.subagents import (
|
||||
SUBAGENT_TASK_TOOL_NAME,
|
||||
)
|
||||
from app.agent.tools.factory import MoviePilotToolFactory
|
||||
from app.core.config import settings
|
||||
from app.utils.identity import SYSTEM_INTERNAL_USER_ID
|
||||
from app.platform.config import settings
|
||||
from app.foundation.identity import SYSTEM_INTERNAL_USER_ID
|
||||
|
||||
|
||||
class _FakeGraphState:
|
||||
@@ -341,7 +341,7 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase):
|
||||
manager = AgentManager()
|
||||
|
||||
with (
|
||||
patch("app.agent.load_jobs_metadata", new=AsyncMock(return_value=[{
|
||||
patch("app.agent.orchestrator.load_jobs_metadata", new=AsyncMock(return_value=[{
|
||||
"id": "job-1",
|
||||
"name": "测试任务",
|
||||
"description": "desc",
|
||||
@@ -364,7 +364,7 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase):
|
||||
manager = AgentManager()
|
||||
|
||||
with (
|
||||
patch("app.agent.load_jobs_metadata", new=AsyncMock(return_value=[])),
|
||||
patch("app.agent.orchestrator.load_jobs_metadata", new=AsyncMock(return_value=[])),
|
||||
patch.object(manager, "process_message", new=AsyncMock()) as process_message,
|
||||
):
|
||||
await manager.heartbeat_check_jobs()
|
||||
@@ -404,28 +404,28 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase):
|
||||
with (
|
||||
patch.object(settings, "LLM_MAX_TOOLS", 0),
|
||||
patch.object(agent, "_initialize_llm", new=AsyncMock(return_value=object())),
|
||||
patch("app.agent.prompt_manager.get_agent_prompt", return_value="PROMPT"),
|
||||
patch("app.agent.create_subagent_middlewares", return_value=([], [])),
|
||||
patch("app.agent.orchestrator.prompt_manager.get_agent_prompt", return_value="PROMPT"),
|
||||
patch("app.agent.orchestrator.create_subagent_middlewares", return_value=([], [])),
|
||||
patch(
|
||||
"app.agent.MoviePilotToolFactory.get_tool_selector_always_include_names",
|
||||
"app.agent.orchestrator.MoviePilotToolFactory.get_tool_selector_always_include_names",
|
||||
return_value=[],
|
||||
),
|
||||
patch(
|
||||
"app.agent.SkillsMiddleware",
|
||||
"app.agent.orchestrator.SkillsMiddleware",
|
||||
side_effect=lambda *args, **kwargs: _fake_skills_middleware(),
|
||||
),
|
||||
patch("app.agent.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"),
|
||||
patch("app.agent.RuntimeConfigMiddleware", side_effect=lambda *args, **kwargs: "runtime"),
|
||||
patch("app.agent.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"),
|
||||
patch("app.agent.orchestrator.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"),
|
||||
patch("app.agent.orchestrator.RuntimeConfigMiddleware", side_effect=lambda *args, **kwargs: "runtime"),
|
||||
patch("app.agent.orchestrator.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"),
|
||||
patch(
|
||||
"app.agent.ActivityLogMiddleware",
|
||||
"app.agent.orchestrator.ActivityLogMiddleware",
|
||||
side_effect=lambda *args, **kwargs: _fake_activity_log_middleware(),
|
||||
),
|
||||
patch("app.agent.SummarizationMiddleware", side_effect=lambda *args, **kwargs: "summary"),
|
||||
patch("app.agent.PatchToolCallsMiddleware", side_effect=lambda *args, **kwargs: "patch"),
|
||||
patch("app.agent.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"),
|
||||
patch("app.agent.InMemorySaver", return_value="checkpointer"),
|
||||
patch("app.agent.create_agent", side_effect=lambda **kwargs: kwargs),
|
||||
patch("app.agent.orchestrator.SummarizationMiddleware", side_effect=lambda *args, **kwargs: "summary"),
|
||||
patch("app.agent.orchestrator.PatchToolCallsMiddleware", side_effect=lambda *args, **kwargs: "patch"),
|
||||
patch("app.agent.orchestrator.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"),
|
||||
patch("app.agent.orchestrator.InMemorySaver", return_value="checkpointer"),
|
||||
patch("app.agent.orchestrator.create_agent", side_effect=lambda **kwargs: kwargs),
|
||||
):
|
||||
created = await agent._create_agent(streaming=False)
|
||||
|
||||
@@ -459,38 +459,38 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase):
|
||||
with (
|
||||
patch.object(settings, "LLM_MAX_TOOLS", 5),
|
||||
patch.object(agent, "_initialize_llm", new=AsyncMock(return_value=object())),
|
||||
patch("app.agent.prompt_manager.get_agent_prompt", return_value="PROMPT"),
|
||||
patch("app.agent.create_subagent_middlewares", return_value=([], [])),
|
||||
patch("app.agent.orchestrator.prompt_manager.get_agent_prompt", return_value="PROMPT"),
|
||||
patch("app.agent.orchestrator.create_subagent_middlewares", return_value=([], [])),
|
||||
patch(
|
||||
"app.agent.MoviePilotToolFactory.get_tool_selector_always_include_names",
|
||||
"app.agent.orchestrator.MoviePilotToolFactory.get_tool_selector_always_include_names",
|
||||
return_value=[],
|
||||
),
|
||||
patch(
|
||||
"app.agent.SkillsMiddleware",
|
||||
"app.agent.orchestrator.SkillsMiddleware",
|
||||
side_effect=lambda *args, **kwargs: _fake_skills_middleware(skill_tool),
|
||||
),
|
||||
patch("app.agent.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"),
|
||||
patch("app.agent.orchestrator.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"),
|
||||
patch(
|
||||
"app.agent.RuntimeConfigMiddleware",
|
||||
"app.agent.orchestrator.RuntimeConfigMiddleware",
|
||||
side_effect=lambda *args, **kwargs: "runtime",
|
||||
),
|
||||
patch("app.agent.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"),
|
||||
patch("app.agent.orchestrator.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"),
|
||||
patch(
|
||||
"app.agent.ActivityLogMiddleware",
|
||||
"app.agent.orchestrator.ActivityLogMiddleware",
|
||||
side_effect=lambda *args, **kwargs: _fake_activity_log_middleware(),
|
||||
),
|
||||
patch(
|
||||
"app.agent.SummarizationMiddleware",
|
||||
"app.agent.orchestrator.SummarizationMiddleware",
|
||||
side_effect=lambda *args, **kwargs: "summary",
|
||||
),
|
||||
patch(
|
||||
"app.agent.PatchToolCallsMiddleware",
|
||||
"app.agent.orchestrator.PatchToolCallsMiddleware",
|
||||
side_effect=lambda *args, **kwargs: "patch",
|
||||
),
|
||||
patch("app.agent.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"),
|
||||
patch("app.agent.ToolSelectorMiddleware", side_effect=_tool_selector),
|
||||
patch("app.agent.InMemorySaver", return_value="checkpointer"),
|
||||
patch("app.agent.create_agent", side_effect=lambda **kwargs: kwargs),
|
||||
patch("app.agent.orchestrator.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"),
|
||||
patch("app.agent.orchestrator.ToolSelectorMiddleware", side_effect=_tool_selector),
|
||||
patch("app.agent.orchestrator.InMemorySaver", return_value="checkpointer"),
|
||||
patch("app.agent.orchestrator.create_agent", side_effect=lambda **kwargs: kwargs),
|
||||
):
|
||||
created = await agent._create_agent(streaming=False)
|
||||
|
||||
@@ -511,37 +511,37 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase):
|
||||
with (
|
||||
patch.object(settings, "LLM_MAX_TOOLS", 0),
|
||||
patch.object(agent, "_initialize_llm", new=AsyncMock(return_value=object())),
|
||||
patch("app.agent.prompt_manager.get_agent_prompt", return_value="PROMPT"),
|
||||
patch("app.agent.create_subagent_middlewares", return_value=([], [])),
|
||||
patch("app.agent.orchestrator.prompt_manager.get_agent_prompt", return_value="PROMPT"),
|
||||
patch("app.agent.orchestrator.create_subagent_middlewares", return_value=([], [])),
|
||||
patch(
|
||||
"app.agent.MoviePilotToolFactory.get_tool_selector_always_include_names",
|
||||
"app.agent.orchestrator.MoviePilotToolFactory.get_tool_selector_always_include_names",
|
||||
return_value=[],
|
||||
),
|
||||
patch(
|
||||
"app.agent.SkillsMiddleware",
|
||||
"app.agent.orchestrator.SkillsMiddleware",
|
||||
side_effect=lambda *args, **kwargs: _fake_skills_middleware(),
|
||||
),
|
||||
patch("app.agent.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"),
|
||||
patch("app.agent.orchestrator.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"),
|
||||
patch(
|
||||
"app.agent.RuntimeConfigMiddleware",
|
||||
"app.agent.orchestrator.RuntimeConfigMiddleware",
|
||||
side_effect=lambda *args, **kwargs: "runtime",
|
||||
),
|
||||
patch("app.agent.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"),
|
||||
patch("app.agent.orchestrator.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"),
|
||||
patch(
|
||||
"app.agent.ActivityLogMiddleware",
|
||||
"app.agent.orchestrator.ActivityLogMiddleware",
|
||||
side_effect=lambda *args, **kwargs: _fake_activity_log_middleware(),
|
||||
),
|
||||
patch(
|
||||
"app.agent.SummarizationMiddleware",
|
||||
"app.agent.orchestrator.SummarizationMiddleware",
|
||||
side_effect=lambda *args, **kwargs: "summary",
|
||||
),
|
||||
patch(
|
||||
"app.agent.PatchToolCallsMiddleware",
|
||||
"app.agent.orchestrator.PatchToolCallsMiddleware",
|
||||
side_effect=lambda *args, **kwargs: "patch",
|
||||
),
|
||||
patch("app.agent.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"),
|
||||
patch("app.agent.InMemorySaver", return_value="checkpointer"),
|
||||
patch("app.agent.create_agent", side_effect=lambda **kwargs: kwargs),
|
||||
patch("app.agent.orchestrator.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"),
|
||||
patch("app.agent.orchestrator.InMemorySaver", return_value="checkpointer"),
|
||||
patch("app.agent.orchestrator.create_agent", side_effect=lambda **kwargs: kwargs),
|
||||
):
|
||||
created = await agent._create_agent(streaming=False)
|
||||
|
||||
@@ -600,40 +600,40 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase):
|
||||
with (
|
||||
patch.object(settings, "LLM_MAX_TOOLS", 5),
|
||||
patch.object(agent, "_initialize_llm", new=AsyncMock(return_value=object())),
|
||||
patch("app.agent.prompt_manager.get_agent_prompt", return_value="PROMPT"),
|
||||
patch("app.agent.create_subagent_middlewares", return_value=([], [])),
|
||||
patch("app.agent.orchestrator.prompt_manager.get_agent_prompt", return_value="PROMPT"),
|
||||
patch("app.agent.orchestrator.create_subagent_middlewares", return_value=([], [])),
|
||||
patch(
|
||||
"app.agent.MoviePilotToolFactory.get_tool_selector_always_include_names",
|
||||
"app.agent.orchestrator.MoviePilotToolFactory.get_tool_selector_always_include_names",
|
||||
return_value=[],
|
||||
),
|
||||
patch(
|
||||
"app.agent.SkillsMiddleware",
|
||||
"app.agent.orchestrator.SkillsMiddleware",
|
||||
side_effect=lambda *args, **kwargs: _fake_skills_middleware(),
|
||||
),
|
||||
patch("app.agent.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"),
|
||||
patch("app.agent.orchestrator.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"),
|
||||
patch(
|
||||
"app.agent.RuntimeConfigMiddleware",
|
||||
"app.agent.orchestrator.RuntimeConfigMiddleware",
|
||||
side_effect=lambda *args, **kwargs: "runtime",
|
||||
),
|
||||
patch("app.agent.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"),
|
||||
patch("app.agent.orchestrator.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"),
|
||||
patch(
|
||||
"app.agent.ActivityLogMiddleware",
|
||||
"app.agent.orchestrator.ActivityLogMiddleware",
|
||||
side_effect=lambda *args, **kwargs: _fake_activity_log_middleware(
|
||||
activity_tool
|
||||
),
|
||||
),
|
||||
patch(
|
||||
"app.agent.SummarizationMiddleware",
|
||||
"app.agent.orchestrator.SummarizationMiddleware",
|
||||
side_effect=lambda *args, **kwargs: "summary",
|
||||
),
|
||||
patch(
|
||||
"app.agent.PatchToolCallsMiddleware",
|
||||
"app.agent.orchestrator.PatchToolCallsMiddleware",
|
||||
side_effect=lambda *args, **kwargs: "patch",
|
||||
),
|
||||
patch("app.agent.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"),
|
||||
patch("app.agent.ToolSelectorMiddleware", side_effect=_tool_selector),
|
||||
patch("app.agent.InMemorySaver", return_value="checkpointer"),
|
||||
patch("app.agent.create_agent", side_effect=lambda **kwargs: kwargs),
|
||||
patch("app.agent.orchestrator.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"),
|
||||
patch("app.agent.orchestrator.ToolSelectorMiddleware", side_effect=_tool_selector),
|
||||
patch("app.agent.orchestrator.InMemorySaver", return_value="checkpointer"),
|
||||
patch("app.agent.orchestrator.create_agent", side_effect=lambda **kwargs: kwargs),
|
||||
):
|
||||
created = await agent._create_agent(streaming=False)
|
||||
|
||||
@@ -655,9 +655,9 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase):
|
||||
with (
|
||||
patch.object(settings, "LLM_MAX_TOOLS", 5),
|
||||
patch.object(agent, "_initialize_llm", new=AsyncMock(return_value=object())),
|
||||
patch("app.agent.prompt_manager.get_agent_prompt", return_value="PROMPT"),
|
||||
patch("app.agent.orchestrator.prompt_manager.get_agent_prompt", return_value="PROMPT"),
|
||||
patch(
|
||||
"app.agent.create_subagent_middlewares",
|
||||
"app.agent.orchestrator.create_subagent_middlewares",
|
||||
return_value=(
|
||||
["subagent"],
|
||||
[
|
||||
@@ -667,35 +667,35 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase):
|
||||
),
|
||||
),
|
||||
patch(
|
||||
"app.agent.MoviePilotToolFactory.get_tool_selector_always_include_names",
|
||||
"app.agent.orchestrator.MoviePilotToolFactory.get_tool_selector_always_include_names",
|
||||
return_value=[],
|
||||
),
|
||||
patch(
|
||||
"app.agent.SkillsMiddleware",
|
||||
"app.agent.orchestrator.SkillsMiddleware",
|
||||
side_effect=lambda *args, **kwargs: _fake_skills_middleware(),
|
||||
),
|
||||
patch("app.agent.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"),
|
||||
patch("app.agent.orchestrator.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"),
|
||||
patch(
|
||||
"app.agent.RuntimeConfigMiddleware",
|
||||
"app.agent.orchestrator.RuntimeConfigMiddleware",
|
||||
side_effect=lambda *args, **kwargs: "runtime",
|
||||
),
|
||||
patch("app.agent.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"),
|
||||
patch("app.agent.orchestrator.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"),
|
||||
patch(
|
||||
"app.agent.ActivityLogMiddleware",
|
||||
"app.agent.orchestrator.ActivityLogMiddleware",
|
||||
side_effect=lambda *args, **kwargs: _fake_activity_log_middleware(),
|
||||
),
|
||||
patch(
|
||||
"app.agent.SummarizationMiddleware",
|
||||
"app.agent.orchestrator.SummarizationMiddleware",
|
||||
side_effect=lambda *args, **kwargs: "summary",
|
||||
),
|
||||
patch(
|
||||
"app.agent.PatchToolCallsMiddleware",
|
||||
"app.agent.orchestrator.PatchToolCallsMiddleware",
|
||||
side_effect=lambda *args, **kwargs: "patch",
|
||||
),
|
||||
patch("app.agent.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"),
|
||||
patch("app.agent.ToolSelectorMiddleware", side_effect=_tool_selector),
|
||||
patch("app.agent.InMemorySaver", return_value="checkpointer"),
|
||||
patch("app.agent.create_agent", side_effect=lambda **kwargs: kwargs),
|
||||
patch("app.agent.orchestrator.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"),
|
||||
patch("app.agent.orchestrator.ToolSelectorMiddleware", side_effect=_tool_selector),
|
||||
patch("app.agent.orchestrator.InMemorySaver", return_value="checkpointer"),
|
||||
patch("app.agent.orchestrator.create_agent", side_effect=lambda **kwargs: kwargs),
|
||||
):
|
||||
await agent._create_agent(streaming=False)
|
||||
|
||||
@@ -715,37 +715,37 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase):
|
||||
with (
|
||||
patch.object(settings, "LLM_MAX_TOOLS", 0),
|
||||
patch.object(agent, "_initialize_llm", new=AsyncMock(return_value=object())),
|
||||
patch("app.agent.prompt_manager.get_agent_prompt", return_value="PROMPT"),
|
||||
patch("app.agent.create_subagent_middlewares", return_value=([], [])),
|
||||
patch("app.agent.orchestrator.prompt_manager.get_agent_prompt", return_value="PROMPT"),
|
||||
patch("app.agent.orchestrator.create_subagent_middlewares", return_value=([], [])),
|
||||
patch(
|
||||
"app.agent.MoviePilotToolFactory.get_tool_selector_always_include_names",
|
||||
"app.agent.orchestrator.MoviePilotToolFactory.get_tool_selector_always_include_names",
|
||||
return_value=[],
|
||||
),
|
||||
patch(
|
||||
"app.agent.SkillsMiddleware",
|
||||
"app.agent.orchestrator.SkillsMiddleware",
|
||||
side_effect=lambda *args, **kwargs: _fake_skills_middleware(),
|
||||
),
|
||||
patch("app.agent.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"),
|
||||
patch("app.agent.orchestrator.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"),
|
||||
patch(
|
||||
"app.agent.RuntimeConfigMiddleware",
|
||||
"app.agent.orchestrator.RuntimeConfigMiddleware",
|
||||
side_effect=lambda *args, **kwargs: "runtime",
|
||||
),
|
||||
patch("app.agent.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"),
|
||||
patch("app.agent.orchestrator.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"),
|
||||
patch(
|
||||
"app.agent.ActivityLogMiddleware",
|
||||
"app.agent.orchestrator.ActivityLogMiddleware",
|
||||
side_effect=lambda *args, **kwargs: _fake_activity_log_middleware(),
|
||||
),
|
||||
patch(
|
||||
"app.agent.SummarizationMiddleware",
|
||||
"app.agent.orchestrator.SummarizationMiddleware",
|
||||
side_effect=lambda *args, **kwargs: "summary",
|
||||
),
|
||||
patch(
|
||||
"app.agent.PatchToolCallsMiddleware",
|
||||
"app.agent.orchestrator.PatchToolCallsMiddleware",
|
||||
side_effect=lambda *args, **kwargs: "patch",
|
||||
),
|
||||
patch("app.agent.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"),
|
||||
patch("app.agent.InMemorySaver", return_value="checkpointer"),
|
||||
patch("app.agent.create_agent", side_effect=lambda **kwargs: kwargs),
|
||||
patch("app.agent.orchestrator.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"),
|
||||
patch("app.agent.orchestrator.InMemorySaver", return_value="checkpointer"),
|
||||
patch("app.agent.orchestrator.create_agent", side_effect=lambda **kwargs: kwargs),
|
||||
):
|
||||
created = await agent._create_agent(streaming=False)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.helper.agent import matches_channel_admin, resolve_config_principal_ids
|
||||
from app.messaging.agent import matches_channel_admin, resolve_config_principal_ids
|
||||
from app.modules.discord import DiscordModule
|
||||
from app.modules.feishu.feishu import Feishu
|
||||
from app.modules.qqbot import QQBotModule
|
||||
|
||||
@@ -7,7 +7,7 @@ from langchain_core.messages import AIMessage, HumanMessage
|
||||
from app.agent import HEARTBEAT_SESSION_PREFIX, MoviePilotAgent
|
||||
from app.agent.memory import memory_manager
|
||||
from app.db.agentchat_oper import AgentChatOper
|
||||
from app.utils.identity import SYSTEM_INTERNAL_USER_ID
|
||||
from app.foundation.identity import SYSTEM_INTERNAL_USER_ID
|
||||
|
||||
|
||||
def test_agent_chat_oper_saves_display_messages_with_channel():
|
||||
|
||||
@@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from app.agent.tools.impl._torrent_search_utils import simplify_search_result
|
||||
from app.agent.tools.impl.get_search_results import GetSearchResultsTool
|
||||
from app.core.context import Context, TorrentInfo
|
||||
from app.domain.context import Context, TorrentInfo
|
||||
|
||||
|
||||
def _build_context(
|
||||
|
||||
@@ -16,7 +16,7 @@ from app.agent.tools.catalog import (
|
||||
ToolIdentityAmbiguousError,
|
||||
)
|
||||
from app.agent.tools.impl.mcp import create_external_mcp_tools
|
||||
from app.core.config import settings
|
||||
from app.platform.config import settings
|
||||
from app.schemas.agent import AgentMcpServerConfig
|
||||
|
||||
|
||||
@@ -75,12 +75,12 @@ async def test_create_agent_reuses_cached_graph_when_signature_matches():
|
||||
"_agent_bundle_signature",
|
||||
new=AsyncMock(return_value=("sig",)),
|
||||
), patch(
|
||||
"app.agent.PluginManager.get_plugin_agent_tools_revision",
|
||||
"app.agent.orchestrator.PluginManager.get_plugin_agent_tools_revision",
|
||||
return_value=0,
|
||||
), patch(
|
||||
"app.agent.agent_mcp_manager.config_signature",
|
||||
"app.agent.orchestrator.agent_mcp_manager.config_signature",
|
||||
return_value="mcp-config",
|
||||
), patch("app.agent.create_agent") as create_agent:
|
||||
), patch("app.agent.orchestrator.create_agent") as create_agent:
|
||||
graph = await agent._create_agent(streaming=False)
|
||||
|
||||
assert graph is cached_graph
|
||||
@@ -117,13 +117,13 @@ async def test_fresh_catalog_cache_hit_skips_tool_and_mcp_discovery() -> None:
|
||||
"_initialize_local_tool_catalogs",
|
||||
side_effect=AssertionError("tool catalog rebuilt"),
|
||||
), patch(
|
||||
"app.agent.PluginManager.get_plugin_agent_tools_revision",
|
||||
"app.agent.orchestrator.PluginManager.get_plugin_agent_tools_revision",
|
||||
return_value=0,
|
||||
), patch(
|
||||
"app.agent.agent_mcp_manager.config_signature",
|
||||
"app.agent.orchestrator.agent_mcp_manager.config_signature",
|
||||
return_value="mcp-config",
|
||||
), patch(
|
||||
"app.agent.agent_mcp_manager.list_enabled_tool_specs",
|
||||
"app.agent.orchestrator.agent_mcp_manager.list_enabled_tool_specs",
|
||||
new=AsyncMock(side_effect=AssertionError("MCP discovery called")),
|
||||
):
|
||||
graph = await agent._create_agent(streaming=False)
|
||||
@@ -182,28 +182,28 @@ async def test_expired_unchanged_catalog_renews_freshness() -> None:
|
||||
agent,
|
||||
"_sync_model_profile",
|
||||
), patch(
|
||||
"app.agent.ServerToolRegistry.resolve_web_search",
|
||||
"app.agent.orchestrator.ServerToolRegistry.resolve_web_search",
|
||||
return_value=SimpleNamespace(use_local_web_search=True),
|
||||
), patch(
|
||||
"app.agent.LLMHelper.get_server_tools",
|
||||
"app.agent.orchestrator.LLMHelper.get_server_tools",
|
||||
return_value=[],
|
||||
), patch(
|
||||
"app.agent.prompt_manager.get_agent_prompt",
|
||||
"app.agent.orchestrator.prompt_manager.get_agent_prompt",
|
||||
return_value="prompt",
|
||||
), patch(
|
||||
"app.agent.SkillsMiddleware",
|
||||
"app.agent.orchestrator.SkillsMiddleware",
|
||||
return_value=SimpleNamespace(name="skills", tools=[]),
|
||||
), patch(
|
||||
"app.agent.create_subagent_middlewares",
|
||||
"app.agent.orchestrator.create_subagent_middlewares",
|
||||
return_value=([], []),
|
||||
), patch(
|
||||
"app.agent.PluginManager.get_plugin_agent_tools_revision",
|
||||
"app.agent.orchestrator.PluginManager.get_plugin_agent_tools_revision",
|
||||
return_value=0,
|
||||
), patch(
|
||||
"app.agent.agent_mcp_manager.config_signature",
|
||||
"app.agent.orchestrator.agent_mcp_manager.config_signature",
|
||||
return_value="mcp-config",
|
||||
), patch(
|
||||
"app.agent.agent_mcp_manager.list_enabled_tool_specs",
|
||||
"app.agent.orchestrator.agent_mcp_manager.list_enabled_tool_specs",
|
||||
new=AsyncMock(return_value=[]),
|
||||
):
|
||||
graph = await agent._create_agent(streaming=False)
|
||||
@@ -412,69 +412,69 @@ async def test_graph_keeps_mcp_first_winner_and_catalogs_all_collisions(
|
||||
),
|
||||
patch.object(agent, "_sync_model_profile"),
|
||||
patch(
|
||||
"app.agent.PluginManager.get_plugin_agent_tools_revision",
|
||||
"app.agent.orchestrator.PluginManager.get_plugin_agent_tools_revision",
|
||||
return_value=0,
|
||||
),
|
||||
patch(
|
||||
"app.agent.agent_mcp_manager.config_signature",
|
||||
"app.agent.orchestrator.agent_mcp_manager.config_signature",
|
||||
return_value="mcp-config",
|
||||
),
|
||||
patch(
|
||||
"app.agent.agent_mcp_manager.list_enabled_tool_specs",
|
||||
"app.agent.orchestrator.agent_mcp_manager.list_enabled_tool_specs",
|
||||
new=AsyncMock(return_value=specs),
|
||||
),
|
||||
patch(
|
||||
"app.agent.ServerToolRegistry.resolve_web_search",
|
||||
"app.agent.orchestrator.ServerToolRegistry.resolve_web_search",
|
||||
return_value=SimpleNamespace(use_local_web_search=True),
|
||||
),
|
||||
patch("app.agent.LLMHelper.get_server_tools", return_value=[]),
|
||||
patch("app.agent.prompt_manager.get_agent_prompt", return_value="prompt"),
|
||||
patch("app.agent.orchestrator.LLMHelper.get_server_tools", return_value=[]),
|
||||
patch("app.agent.orchestrator.prompt_manager.get_agent_prompt", return_value="prompt"),
|
||||
patch(
|
||||
"app.agent.create_subagent_middlewares",
|
||||
"app.agent.orchestrator.create_subagent_middlewares",
|
||||
side_effect=_capture_subagents,
|
||||
),
|
||||
patch(
|
||||
"app.agent.MoviePilotToolFactory.get_tool_selector_always_include_names",
|
||||
"app.agent.orchestrator.MoviePilotToolFactory.get_tool_selector_always_include_names",
|
||||
return_value=[],
|
||||
),
|
||||
patch(
|
||||
"app.agent.SkillsMiddleware",
|
||||
"app.agent.orchestrator.SkillsMiddleware",
|
||||
return_value=SimpleNamespace(name="skills", tools=[skill_tool]),
|
||||
),
|
||||
patch(
|
||||
"app.agent.ActivityLogMiddleware",
|
||||
"app.agent.orchestrator.ActivityLogMiddleware",
|
||||
return_value=SimpleNamespace(name="activity", tools=[activity_tool]),
|
||||
),
|
||||
patch(
|
||||
"app.agent.JobsMiddleware",
|
||||
"app.agent.orchestrator.JobsMiddleware",
|
||||
return_value=SimpleNamespace(name="jobs"),
|
||||
),
|
||||
patch(
|
||||
"app.agent.RuntimeConfigMiddleware",
|
||||
"app.agent.orchestrator.RuntimeConfigMiddleware",
|
||||
return_value=SimpleNamespace(name="runtime"),
|
||||
),
|
||||
patch(
|
||||
"app.agent.MemoryMiddleware",
|
||||
"app.agent.orchestrator.MemoryMiddleware",
|
||||
return_value=SimpleNamespace(name="memory"),
|
||||
),
|
||||
patch(
|
||||
"app.agent.SummarizationMiddleware",
|
||||
"app.agent.orchestrator.SummarizationMiddleware",
|
||||
return_value=SimpleNamespace(name="summary"),
|
||||
),
|
||||
patch(
|
||||
"app.agent.PatchToolCallsMiddleware",
|
||||
"app.agent.orchestrator.PatchToolCallsMiddleware",
|
||||
return_value=SimpleNamespace(name="patch"),
|
||||
),
|
||||
patch(
|
||||
"app.agent.UsageMiddleware",
|
||||
"app.agent.orchestrator.UsageMiddleware",
|
||||
return_value=SimpleNamespace(name="usage"),
|
||||
),
|
||||
patch(
|
||||
"app.agent.ToolSelectorMiddleware",
|
||||
"app.agent.orchestrator.ToolSelectorMiddleware",
|
||||
side_effect=_capture_selector,
|
||||
),
|
||||
patch("app.agent.InMemorySaver", return_value=object()),
|
||||
patch("app.agent.create_agent", side_effect=_capture_agent),
|
||||
patch("app.agent.orchestrator.InMemorySaver", return_value=object()),
|
||||
patch("app.agent.orchestrator.create_agent", side_effect=_capture_agent),
|
||||
patch.object(settings, "LLM_MAX_TOOLS", max_tools),
|
||||
]
|
||||
with ExitStack() as stack:
|
||||
@@ -539,7 +539,7 @@ async def test_execute_agent_sends_only_latest_message_on_cache_hit():
|
||||
agent._create_agent = _create_agent
|
||||
messages = [HumanMessage(content="上一轮"), HumanMessage(content="本轮")]
|
||||
|
||||
with patch("app.agent.eventmanager.send_event"):
|
||||
with patch("app.agent.orchestrator.eventmanager.send_event"):
|
||||
await agent._execute_agent(messages)
|
||||
|
||||
assert agent._streamed_output == "ok"
|
||||
|
||||
@@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch
|
||||
from app.agent import MoviePilotAgent
|
||||
from app.agent.llm import AgentCapabilityManager, LLMHelper
|
||||
from app.chain.message import MessageChain
|
||||
from app.core.config import settings
|
||||
from app.platform.config import settings
|
||||
from app.schemas.types import MessageChannel
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ from app.agent.tools.impl.send_local_file import SendLocalFileInput
|
||||
from app.agent import MoviePilotAgent, AgentChain
|
||||
from app.agent.llm import AgentCapabilityManager
|
||||
from app.chain.message import MessageChain
|
||||
from app.core.config import settings
|
||||
from app.platform.config import settings
|
||||
from app.agent.llm import LLMHelper
|
||||
from app.modules.discord import DiscordModule
|
||||
from app.modules.qqbot import QQBotModule
|
||||
|
||||
@@ -10,12 +10,12 @@ from app.agent.tools.impl.ask_user_choice import (
|
||||
UserChoiceOptionInput,
|
||||
)
|
||||
from app.agent.tools.impl.send_message import SendMessageTool
|
||||
from app.helper.interaction import (
|
||||
from app.messaging.interaction import (
|
||||
AgentInteractionOption,
|
||||
agent_interaction_manager,
|
||||
)
|
||||
from app.chain.message import MessageChain
|
||||
from app.core.config import settings
|
||||
from app.platform.config import settings
|
||||
from app.schemas.types import MessageChannel
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app import agent as agent_module
|
||||
import app.agent.orchestrator as agent_module
|
||||
from app.agent import AgentManager
|
||||
from app.agent.memory import MemoryManager
|
||||
from app.startup import agent_initializer, modules_initializer
|
||||
|
||||
@@ -5,7 +5,7 @@ from tempfile import TemporaryDirectory
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from app.core.config import settings
|
||||
from app.platform.config import settings
|
||||
from app.schemas.message import ChannelCapability, ChannelCapabilityManager
|
||||
from app.schemas.types import MessageChannel
|
||||
|
||||
@@ -199,7 +199,7 @@ class AgentCapabilityManagerTest(unittest.TestCase):
|
||||
]
|
||||
|
||||
with patch(
|
||||
"app.helper.service.ServiceConfigHelper.get_notification_configs",
|
||||
"app.extensions.service_registry.ServiceConfigHelper.get_notification_configs",
|
||||
return_value=configs,
|
||||
):
|
||||
self.assertTrue(
|
||||
|
||||
@@ -3,7 +3,7 @@ from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.agent import MoviePilotAgent
|
||||
from app.core.config import settings
|
||||
from app.platform.config import settings
|
||||
from app.schemas import AgentLLMProviderEventData
|
||||
from app.schemas.types import ChainEventType
|
||||
|
||||
@@ -20,7 +20,7 @@ def test_resolve_llm_runtime_config_uses_system_thinking_level(monkeypatch) -> N
|
||||
return SimpleNamespace(event_data=AgentLLMProviderEventData())
|
||||
|
||||
with patch(
|
||||
"app.agent.eventmanager.async_send_event",
|
||||
"app.agent.orchestrator.eventmanager.async_send_event",
|
||||
new=AsyncMock(side_effect=return_empty_config),
|
||||
):
|
||||
runtime_config = asyncio.run(agent._resolve_llm_runtime_config())
|
||||
@@ -39,7 +39,7 @@ def test_resolve_llm_runtime_config_prefers_plugin_thinking_level(monkeypatch) -
|
||||
return SimpleNamespace(event_data=event_data)
|
||||
|
||||
with patch(
|
||||
"app.agent.eventmanager.async_send_event",
|
||||
"app.agent.orchestrator.eventmanager.async_send_event",
|
||||
new=AsyncMock(side_effect=override_thinking_level),
|
||||
):
|
||||
runtime_config = asyncio.run(agent._resolve_llm_runtime_config())
|
||||
@@ -59,7 +59,7 @@ def test_resolve_llm_runtime_config_uses_system_api_protocol(monkeypatch) -> Non
|
||||
return SimpleNamespace(event_data=AgentLLMProviderEventData())
|
||||
|
||||
with patch(
|
||||
"app.agent.eventmanager.async_send_event",
|
||||
"app.agent.orchestrator.eventmanager.async_send_event",
|
||||
new=AsyncMock(side_effect=return_empty_config),
|
||||
):
|
||||
runtime_config = asyncio.run(agent._resolve_llm_runtime_config())
|
||||
@@ -78,7 +78,7 @@ def test_resolve_llm_runtime_config_prefers_plugin_api_protocol(monkeypatch) ->
|
||||
return SimpleNamespace(event_data=event_data)
|
||||
|
||||
with patch(
|
||||
"app.agent.eventmanager.async_send_event",
|
||||
"app.agent.orchestrator.eventmanager.async_send_event",
|
||||
new=AsyncMock(side_effect=override_api_protocol),
|
||||
):
|
||||
runtime_config = asyncio.run(agent._resolve_llm_runtime_config())
|
||||
@@ -97,7 +97,7 @@ def test_resolve_llm_runtime_config_prefers_plugin_web_search_mode(monkeypatch)
|
||||
return SimpleNamespace(event_data=event_data)
|
||||
|
||||
with patch(
|
||||
"app.agent.eventmanager.async_send_event",
|
||||
"app.agent.orchestrator.eventmanager.async_send_event",
|
||||
new=AsyncMock(side_effect=override_web_search_mode),
|
||||
):
|
||||
runtime_config = asyncio.run(agent._resolve_llm_runtime_config())
|
||||
|
||||
@@ -8,11 +8,11 @@ from app.agent.tools.impl.ask_user_choice import (
|
||||
)
|
||||
from app.agent.tools.impl.send_message import SendMessageTool
|
||||
from app.chain.message import MessageChain
|
||||
from app.core.config import settings
|
||||
from app.platform.config import settings
|
||||
from app.db import SessionFactory
|
||||
from app.db.message_oper import MessageOper
|
||||
from app.db.models.message import Message
|
||||
from app.helper.interaction import AgentInteractionOption, agent_interaction_manager, media_interaction_manager
|
||||
from app.messaging.interaction import AgentInteractionOption, agent_interaction_manager, media_interaction_manager
|
||||
from app.schemas.types import MessageChannel, NotificationType
|
||||
|
||||
|
||||
@@ -165,10 +165,10 @@ def test_ask_user_choice_message_is_not_recorded_to_message_history():
|
||||
|
||||
try:
|
||||
with patch(
|
||||
"app.core.event.EventManager.async_send_event",
|
||||
"app.platform.events.EventManager.async_send_event",
|
||||
new_callable=AsyncMock,
|
||||
) as async_send_event, patch(
|
||||
"app.helper.message.MessageQueueManager.async_send_message",
|
||||
"app.messaging.message.MessageQueueManager.async_send_message",
|
||||
new_callable=AsyncMock,
|
||||
) as async_send_message:
|
||||
result = asyncio.run(
|
||||
@@ -202,7 +202,7 @@ def test_agent_final_reply_disables_notification_history():
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.agent.AgentChain.async_post_message",
|
||||
"app.agent.orchestrator.AgentChain.async_post_message",
|
||||
new_callable=AsyncMock,
|
||||
) as async_post_message:
|
||||
asyncio.run(agent.send_agent_message("已完成处理"))
|
||||
|
||||
@@ -25,7 +25,7 @@ from app.agent.tools.impl.recognize_media import RecognizeMediaTool
|
||||
from app.agent.tools.impl.scrape_metadata import ScrapeMetadataTool
|
||||
from app.agent.tools.impl.search_media import SearchMediaTool
|
||||
from app.agent.tools.impl.search_torrents import SearchTorrentsTool
|
||||
from app.core.context import (
|
||||
from app.domain.context import (
|
||||
MUSIC_ENTITY_ALBUM,
|
||||
MUSIC_ENTITY_ARTIST,
|
||||
Context,
|
||||
@@ -34,7 +34,7 @@ from app.core.context import (
|
||||
MusicInfo,
|
||||
TorrentInfo,
|
||||
)
|
||||
from app.core.meta import MetaMusic
|
||||
from app.domain.meta.metamusic import MetaMusic
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.agent.prompt import prompt_manager
|
||||
from app.core.config import settings
|
||||
from app.platform.config import settings
|
||||
from app.schemas.types import MessageChannel
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from app.agent.prompt import PromptManager
|
||||
from app.agent.tools.impl.query_system_settings import QuerySystemSettingsInput
|
||||
from app.core.config import settings
|
||||
from app.platform.config import settings
|
||||
|
||||
|
||||
def test_moviepilot_info_does_not_expose_api_token_or_database_password(monkeypatch) -> None:
|
||||
|
||||
@@ -6,7 +6,7 @@ from unittest.mock import patch
|
||||
from app.agent.tools.factory import MoviePilotToolFactory
|
||||
from app.agent.tools.impl.recognize_captcha import RecognizeCaptchaTool
|
||||
from app.agent.tools.manager import MoviePilotToolsManager
|
||||
from app.helper.ocr import OcrHelper
|
||||
from app.integrations.ocr import OcrHelper
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
@@ -67,7 +67,7 @@ def test_ocr_helper_extracts_data_url_base64_without_downloading_image():
|
||||
image_b64 = base64.b64encode(b"captcha-image").decode()
|
||||
image_url = f"data:image/png;base64,{image_b64}"
|
||||
|
||||
with patch("app.helper.ocr.RequestUtils") as request_utils:
|
||||
with patch("app.integrations.ocr.RequestUtils") as request_utils:
|
||||
request_utils.return_value.post_res.return_value = _FakeResponse(
|
||||
payload={"result": "a8k2"}
|
||||
)
|
||||
@@ -86,7 +86,7 @@ def test_ocr_helper_normalizes_data_url_base64_padding():
|
||||
"""data:image 地址缺少 padding 时应补齐后提交给 OCR 服务。"""
|
||||
image_url = "data:image/jpeg;base64,YWJjZA"
|
||||
|
||||
with patch("app.helper.ocr.RequestUtils") as request_utils:
|
||||
with patch("app.integrations.ocr.RequestUtils") as request_utils:
|
||||
request_utils.return_value.post_res.return_value = _FakeResponse(
|
||||
payload={"result": "z9k2"}
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ from app.agent.tools.impl.read_file import ReadFileTool
|
||||
from app.agent.tools.impl.write_file import WriteFileTool
|
||||
from app.agent.tools.manager import MoviePilotToolsManager
|
||||
from app.agent import MoviePilotAgent
|
||||
from app.core.config import settings
|
||||
from app.platform.config import settings
|
||||
from app.modules.feishu import FeishuModule
|
||||
from app.modules.telegram import TelegramModule
|
||||
from app.schemas.types import MessageChannel
|
||||
@@ -355,7 +355,7 @@ def test_channel_agent_admin_user_id_does_not_bypass_user_lookup():
|
||||
username="normal-user",
|
||||
)
|
||||
|
||||
with patch("app.agent.UserOper") as user_oper:
|
||||
with patch("app.agent.orchestrator.UserOper") as user_oper:
|
||||
user_oper.return_value.async_get_by_name.return_value = SimpleNamespace(
|
||||
is_superuser=False
|
||||
)
|
||||
@@ -377,7 +377,7 @@ def test_channel_agent_rejects_local_admin_username_without_trusted_principal():
|
||||
)
|
||||
agent.is_channel_admin = False
|
||||
|
||||
with patch("app.agent.UserOper") as user_oper:
|
||||
with patch("app.agent.orchestrator.UserOper") as user_oper:
|
||||
user_oper.return_value.async_get_by_name = AsyncMock(
|
||||
return_value=SimpleNamespace(is_superuser=True)
|
||||
)
|
||||
@@ -400,7 +400,7 @@ def test_channel_agent_accepts_trusted_admin_principal_without_local_user():
|
||||
)
|
||||
agent.is_channel_admin = True
|
||||
|
||||
with patch("app.agent.UserOper") as user_oper:
|
||||
with patch("app.agent.orchestrator.UserOper") as user_oper:
|
||||
context = asyncio.run(
|
||||
agent._build_tool_context(should_dispatch_reply=True)
|
||||
)
|
||||
|
||||
@@ -36,13 +36,13 @@ from app.agent.tools.impl.update_agent_task import (
|
||||
UpdateAgentTaskTool,
|
||||
)
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.core.config import settings
|
||||
from app.platform.config import settings
|
||||
from app.db import SessionFactory
|
||||
from app.db.agenttask_oper import AgentTaskOper
|
||||
from app.db.models.agenttask import AgentTask
|
||||
from app.schemas import ScheduleInfo
|
||||
from app.scheduler import Scheduler
|
||||
from app.utils.timer import TimerUtils
|
||||
from app.platform.scheduling import TimerUtils
|
||||
|
||||
|
||||
class _FakeAgentTaskScheduler:
|
||||
@@ -385,7 +385,7 @@ async def test_interrupted_date_task_manual_run_disables_and_removes_job(
|
||||
scheduler.init_agent_task_jobs()
|
||||
|
||||
process_message = AsyncMock(return_value="执行完成")
|
||||
monkeypatch.setattr("app.agent.agent_manager.process_message", process_message)
|
||||
monkeypatch.setattr("app.agent.orchestrator.agent_manager.process_message", process_message)
|
||||
|
||||
assert await scheduler.execute_agent_task(
|
||||
task.id,
|
||||
@@ -410,7 +410,7 @@ async def test_scheduler_propagates_scheduled_trigger_source(monkeypatch) -> Non
|
||||
task = _add_agent_task("cron", "0 * * * *", "scheduled-source")
|
||||
scheduler = _build_agent_task_scheduler()
|
||||
execute = AsyncMock(return_value=(True, "执行完成"))
|
||||
monkeypatch.setattr("app.agent.agent_manager.execute_scheduled_task", execute)
|
||||
monkeypatch.setattr("app.agent.orchestrator.agent_manager.execute_scheduled_task", execute)
|
||||
|
||||
assert await scheduler.execute_agent_task(task.id) == (True, "执行完成")
|
||||
execute.assert_awaited_once_with(task.id, trigger_source="scheduled")
|
||||
@@ -1131,7 +1131,7 @@ async def test_background_agent_final_message_is_broadcast() -> None:
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.agent.AgentChain.async_post_message",
|
||||
"app.agent.orchestrator.AgentChain.async_post_message",
|
||||
new_callable=AsyncMock,
|
||||
) as post_message:
|
||||
await agent.send_agent_message("任务完成", title="MoviePilot助手")
|
||||
|
||||
@@ -8,7 +8,7 @@ from app.agent.tools.impl.search_web import (
|
||||
DEFAULT_SEARCH_ENGINE,
|
||||
SearchWebTool,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.platform.config import settings
|
||||
|
||||
|
||||
class TestAgentSearchWebTool(unittest.TestCase):
|
||||
|
||||
@@ -202,7 +202,7 @@ def test_confirm_executes_once_without_model_or_history() -> None:
|
||||
patch.object(agent, "_is_system_admin_context", new=AsyncMock(return_value=True)),
|
||||
patch.object(agent, "_execute_agent", new=AsyncMock()) as execute_agent,
|
||||
patch.object(agent, "_save_display_history_messages") as save_display,
|
||||
patch("app.agent.memory_manager.save_agent_messages") as save_messages,
|
||||
patch("app.agent.orchestrator.memory_manager.save_agent_messages") as save_messages,
|
||||
patch.object(
|
||||
QuerySystemSettingsTool,
|
||||
"_load_setting_value",
|
||||
@@ -402,7 +402,7 @@ def test_private_delivery_requests_literal_plain_text() -> None:
|
||||
response = SimpleNamespace(success=True)
|
||||
|
||||
with patch(
|
||||
"app.agent.AgentChain.send_direct_message",
|
||||
"app.agent.orchestrator.AgentChain.send_direct_message",
|
||||
return_value=response,
|
||||
) as send_direct:
|
||||
delivered = asyncio.run(
|
||||
|
||||
@@ -23,7 +23,7 @@ from langchain_core.tools import tool
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
from pydantic import Field
|
||||
|
||||
import app.agent as agent_module
|
||||
import app.agent.orchestrator as agent_module
|
||||
from app.agent.memory import memory_manager
|
||||
from app.agent.middleware.runtime_config import RuntimeConfigMiddleware
|
||||
from app.agent.middleware.summarization import (
|
||||
@@ -1221,7 +1221,7 @@ def test_summary_failure_preserves_database_history():
|
||||
agent.send_agent_message = AsyncMock()
|
||||
|
||||
with (
|
||||
patch("app.agent.eventmanager.send_event") as send_usage_event,
|
||||
patch("app.agent.orchestrator.eventmanager.send_event") as send_usage_event,
|
||||
):
|
||||
result, _ = asyncio.run(
|
||||
agent._execute_agent(
|
||||
|
||||
@@ -9,7 +9,7 @@ from app.agent.tools.impl._system_setting_utils import list_setting_specs
|
||||
from app.agent.tools.impl.query_system_settings import QuerySystemSettingsTool
|
||||
from app.agent.tools.impl.update_system_settings import UpdateSystemSettingsTool
|
||||
from app.agent.tools.manager import MoviePilotToolsManager
|
||||
from app.core.config import Settings, settings
|
||||
from app.platform.config import Settings, settings
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
|
||||
|
||||
@@ -337,7 +337,7 @@ async def test_query_task_returns_owner_scoped_ten_recent_runs(monkeypatch) -> N
|
||||
@pytest.mark.anyio
|
||||
async def test_agent_manager_records_manual_trigger_source(monkeypatch) -> None:
|
||||
"""真实执行入口应把手动触发来源写入对应 run。"""
|
||||
monkeypatch.setattr("app.agent.settings.AI_AGENT_ENABLE", True)
|
||||
monkeypatch.setattr("app.agent.orchestrator.settings.AI_AGENT_ENABLE", True)
|
||||
task = _add_task("run-manager")
|
||||
manager = AgentManager()
|
||||
captured = {}
|
||||
|
||||
@@ -6,7 +6,7 @@ from langchain_core.messages import AIMessage
|
||||
|
||||
from app.agent import MoviePilotAgent
|
||||
from app.agent.memory import memory_manager
|
||||
from app.core.config import settings
|
||||
from app.platform.config import settings
|
||||
from app.schemas.types import ChainEventType, EventType
|
||||
|
||||
|
||||
@@ -62,11 +62,11 @@ def test_initialize_llm_uses_chain_event_selection(monkeypatch) -> None:
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.agent.eventmanager.async_send_event",
|
||||
"app.agent.orchestrator.eventmanager.async_send_event",
|
||||
new=AsyncMock(side_effect=select_provider),
|
||||
) as send_event,
|
||||
patch(
|
||||
"app.agent.LLMHelper.get_llm",
|
||||
"app.agent.orchestrator.LLMHelper.get_llm",
|
||||
new=AsyncMock(return_value=fake_llm),
|
||||
) as get_llm,
|
||||
):
|
||||
@@ -132,7 +132,7 @@ def test_execute_agent_broadcasts_usage_on_success() -> None:
|
||||
with (
|
||||
patch.object(agent, "_create_agent", new=create_agent),
|
||||
patch.object(memory_manager, "save_agent_messages"),
|
||||
patch("app.agent.eventmanager.send_event") as send_event,
|
||||
patch("app.agent.orchestrator.eventmanager.send_event") as send_event,
|
||||
):
|
||||
asyncio.run(agent._execute_agent([]))
|
||||
|
||||
@@ -174,7 +174,7 @@ def test_execute_agent_broadcasts_usage_on_failure() -> None:
|
||||
|
||||
with (
|
||||
patch.object(agent, "_create_agent", new=create_agent),
|
||||
patch("app.agent.eventmanager.send_event") as send_event,
|
||||
patch("app.agent.orchestrator.eventmanager.send_event") as send_event,
|
||||
):
|
||||
result, _ = asyncio.run(agent._execute_agent([]))
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from app.agent.tools.catalog import (
|
||||
ToolIdentityAmbiguousError,
|
||||
)
|
||||
from app.agent.tools.factory import MoviePilotToolFactory
|
||||
from app.core.plugin import PluginManager
|
||||
from app.extensions.plugin_manager import PluginManager
|
||||
|
||||
|
||||
class _Arguments(BaseModel):
|
||||
|
||||
@@ -12,8 +12,8 @@ from app.agent.tools.factory import MoviePilotToolFactory
|
||||
from app.agent.tools.impl.ask_user_choice import AskUserChoiceInput, AskUserChoiceTool
|
||||
from app.agent.tools.impl.send_local_file import SendLocalFileTool
|
||||
from app.agent.tools.impl.send_voice_message import SendVoiceMessageTool
|
||||
from app.core.plugin import PluginManager
|
||||
from app.utils.singleton import Singleton
|
||||
from app.extensions.plugin_manager import PluginManager
|
||||
from app.foundation.singleton import Singleton
|
||||
|
||||
|
||||
class DemoAgentTool(MoviePilotTool):
|
||||
|
||||
@@ -7,7 +7,7 @@ import pytest
|
||||
from langchain_core.messages import ToolMessage
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
import app.agent as agent_module
|
||||
import app.agent.orchestrator as agent_module
|
||||
from app.agent.middleware.activity_log import ActivityLogMiddleware
|
||||
from app.agent.middleware.memory import MemoryMiddleware
|
||||
from app.agent.middleware.policy import AgentPolicyMiddleware
|
||||
|
||||
@@ -13,7 +13,7 @@ from app.agent.middleware.subagents import is_subagent_stream_metadata
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.impl.send_voice_message import SendVoiceMessageTool
|
||||
from app.api.endpoints.openai import _OpenAIStreamingHandler
|
||||
from app.core.config import settings
|
||||
from app.platform.config import settings
|
||||
from app.schemas.message import MessageResponse
|
||||
from app.schemas.types import MessageChannel, NotificationType
|
||||
|
||||
|
||||
@@ -180,7 +180,7 @@ def test_create_agent_config_uses_llm_max_iterations():
|
||||
|
||||
agent._create_agent = _create_agent
|
||||
agent.stream_handler.stop_streaming = lambda: asyncio.sleep(0, result=(False, ""))
|
||||
with patch("app.agent.settings.LLM_MAX_ITERATIONS", 7):
|
||||
with patch("app.agent.orchestrator.settings.LLM_MAX_ITERATIONS", 7):
|
||||
await agent._execute_agent([])
|
||||
return fake_agent.config
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ def test_update_download_tasks_resolves_downloader_and_updates_all_supported_fie
|
||||
未显式传下载器时,应先按 Hash 解析任务所属下载器,再一次性执行多项修改。
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
"app.helper.directory.DirectoryHelper.get_download_dirs",
|
||||
"app.services.directory.DirectoryHelper.get_download_dirs",
|
||||
lambda _self: _download_dirs(),
|
||||
)
|
||||
hash_value = "a" * 40
|
||||
|
||||
@@ -3,7 +3,7 @@ from datetime import date
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
from app.api.endpoints import anilist as anilist_endpoint
|
||||
from app.core.context import MediaInfo
|
||||
from app.domain.context import MediaInfo
|
||||
from app.modules.anilist import AniListModule
|
||||
from app.modules.anilist.anilist import AniListApi
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ from xml.dom import minidom
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.meta import MetaBase
|
||||
from app.helper.scraper import MediaScraperHelper
|
||||
from app.domain.context import MediaInfo
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.domain.scraper import MediaScraperHelper
|
||||
from app.modules.anilist import AniListModule
|
||||
from app.modules.anilist.anilist import AniListApi
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
|
||||
@@ -3,7 +3,7 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.metainfo import MetaInfo, MetaInfoPath, find_metainfo
|
||||
from app.domain.metainfo import MetaInfo, MetaInfoPath, find_metainfo
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
|
||||
@@ -59,10 +59,10 @@ def test_metainfo_path_inherits_bangumi_id_from_parent() -> None:
|
||||
def test_extended_ids_fall_back_when_installed_rust_is_old() -> None:
|
||||
"""当前Rust扩展缺少新字段时应直接使用Python解析器。"""
|
||||
with patch(
|
||||
"app.core.metainfo.rust_accel.supports_extended_media_ids",
|
||||
"app.infrastructure.rust.supports_extended_media_ids",
|
||||
return_value=False,
|
||||
), patch(
|
||||
"app.core.metainfo.rust_accel.find_metainfo",
|
||||
"app.infrastructure.rust.find_metainfo",
|
||||
side_effect=AssertionError("旧Rust扩展不应处理扩展来源ID"),
|
||||
):
|
||||
_, metainfo = find_metainfo("Frieren [anilist=154587]")
|
||||
@@ -81,7 +81,7 @@ def test_extended_ids_fall_back_when_installed_rust_is_old() -> None:
|
||||
)
|
||||
def test_python_metainfo_rejects_zero_identity_and_removes_tag(title: str) -> None:
|
||||
"""Python 标签解析器应移除零值标签,但不得生成媒体身份。"""
|
||||
with patch("app.core.metainfo.rust_accel.find_metainfo", return_value=None):
|
||||
with patch("app.infrastructure.rust.find_metainfo", return_value=None):
|
||||
parsed_title, metainfo = find_metainfo(title)
|
||||
|
||||
assert metainfo["media_source"] is None
|
||||
@@ -100,7 +100,7 @@ def test_metainfo_normalizes_zero_identity_from_old_rust_extension() -> None:
|
||||
},
|
||||
}
|
||||
with patch(
|
||||
"app.core.metainfo.rust_accel.find_metainfo",
|
||||
"app.infrastructure.rust.find_metainfo",
|
||||
return_value=rust_result,
|
||||
):
|
||||
parsed_title, metainfo = find_metainfo("Movie [tmdbid=0]")
|
||||
|
||||
@@ -17,7 +17,7 @@ from app.api.endpoints import storage as storage_endpoint
|
||||
from app.api.endpoints import system as system_endpoint
|
||||
from app.api.endpoints import transfer as transfer_endpoint
|
||||
from app.api.endpoints import user as user_endpoint
|
||||
from app.core.security import verify_resource_token
|
||||
from app.security.access import verify_resource_token
|
||||
from app.db.user_oper import (
|
||||
get_current_active_manage_user,
|
||||
get_current_active_manage_user_async,
|
||||
|
||||
@@ -17,7 +17,7 @@ from app.factory import (
|
||||
localized_unhandled_exception_handler,
|
||||
localized_validation_exception_handler,
|
||||
)
|
||||
from app.helper.locale import LocaleHelper
|
||||
from app.platform.localization import LocaleHelper
|
||||
from app.schemas.common import JsonData
|
||||
from app.schemas.response import Response
|
||||
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parents[1]
|
||||
APP_ROOT = PROJECT_ROOT / "app"
|
||||
LEGACY_ROOTS = ("app.core", "app.helper", "app.utils")
|
||||
LEGACY_MODULES = {"app.log"}
|
||||
IMPLEMENTATION_ROOTS = (
|
||||
"app.agent.skills",
|
||||
"app.domain",
|
||||
"app.extensions",
|
||||
"app.foundation",
|
||||
"app.infrastructure",
|
||||
"app.integrations",
|
||||
"app.messaging",
|
||||
"app.platform",
|
||||
"app.security",
|
||||
"app.services",
|
||||
)
|
||||
CYCLE_ROOTS = (*IMPLEMENTATION_ROOTS, "app.compat", "app.sdk")
|
||||
RETIRED_CANONICAL_FILES = (
|
||||
"app/infrastructure/package_installer.py",
|
||||
"app/infrastructure/resource_updater.py",
|
||||
"app/infrastructure/rust_accel.py",
|
||||
"app/messaging/agent_bridge.py",
|
||||
"app/platform/config_reload.py",
|
||||
"app/platform/rate_limit.py",
|
||||
"app/platform/thread_pool.py",
|
||||
"app/services/filter_rules.py",
|
||||
"app/services/transfer_history.py",
|
||||
"app/extensions/module_loader.py",
|
||||
"app/extensions/plugin_market.py",
|
||||
"app/extensions/plugin_repository.py",
|
||||
"app/infrastructure/http.py",
|
||||
"app/integrations/rss.py",
|
||||
"app/security/two_factor.py",
|
||||
"app/infrastructure/gc.py",
|
||||
"app/infrastructure/web.py",
|
||||
"app/security/url_safety.py",
|
||||
"app/domain/mediaserver.py",
|
||||
"app/domain/nfo.py",
|
||||
"app/log.py",
|
||||
"app/foundation/diagnostics.py",
|
||||
"app/infrastructure/log.py",
|
||||
"app/startup/diagnostics_initializer.py",
|
||||
"app/startup/log_initializer.py",
|
||||
"app/messaging/notification.py",
|
||||
"app/messaging/webpush.py",
|
||||
)
|
||||
FORBIDDEN_CAPABILITY_IMPORTS = {
|
||||
"foundation": {
|
||||
"domain", "extensions", "infrastructure", "integrations", "messaging",
|
||||
"platform", "security", "services", "sdk", "compat", "log",
|
||||
},
|
||||
"domain": {
|
||||
"extensions", "integrations", "messaging", "security", "services",
|
||||
"sdk", "compat", "db", "infrastructure", "platform", "log",
|
||||
},
|
||||
"platform": {
|
||||
"domain", "extensions", "integrations", "messaging", "security",
|
||||
"services", "sdk", "compat",
|
||||
},
|
||||
"infrastructure": {
|
||||
"domain", "extensions", "integrations", "messaging", "security",
|
||||
"services", "sdk", "compat",
|
||||
},
|
||||
"extensions": {"messaging", "security", "services", "sdk", "compat"},
|
||||
"integrations": {
|
||||
"extensions", "messaging", "security", "services", "sdk", "compat",
|
||||
},
|
||||
"messaging": {"integrations", "security", "services", "sdk", "compat"},
|
||||
"security": {"extensions", "messaging", "services", "sdk", "compat"},
|
||||
"services": {"integrations", "messaging", "sdk", "compat"},
|
||||
"compat": {
|
||||
"domain", "extensions", "foundation", "infrastructure", "integrations",
|
||||
"messaging", "platform", "security", "services", "sdk",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _discover_modules() -> dict[str, Path]:
|
||||
"""建立实际 Python 模块名到源码路径的映射。"""
|
||||
modules: dict[str, Path] = {}
|
||||
for path in APP_ROOT.rglob("*.py"):
|
||||
relative = path.relative_to(PROJECT_ROOT).with_suffix("")
|
||||
parts = list(relative.parts)
|
||||
if parts[-1] == "__init__":
|
||||
parts.pop()
|
||||
modules[".".join(parts)] = path
|
||||
return modules
|
||||
|
||||
|
||||
def _resolve_imports(
|
||||
module_name: str,
|
||||
path: Path,
|
||||
known_modules: set[str],
|
||||
) -> set[str]:
|
||||
"""解析一个模块的静态导入,并计入 Python 必然初始化的父包。"""
|
||||
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
|
||||
package = module_name if path.name == "__init__.py" else module_name.rpartition(".")[0]
|
||||
dependencies: set[str] = set()
|
||||
for node in ast.walk(tree):
|
||||
candidates: list[str] = []
|
||||
if isinstance(node, ast.Import):
|
||||
candidates.extend(alias.name for alias in node.names)
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
if node.level:
|
||||
package_parts = package.split(".")
|
||||
base = ".".join(package_parts[: len(package_parts) - node.level + 1])
|
||||
imported_module = ".".join(
|
||||
part for part in (base, node.module or "") if part
|
||||
)
|
||||
else:
|
||||
imported_module = node.module or ""
|
||||
if imported_module:
|
||||
candidates.append(imported_module)
|
||||
candidates.extend(
|
||||
f"{imported_module}.{alias.name}"
|
||||
for alias in node.names
|
||||
if alias.name != "*"
|
||||
)
|
||||
|
||||
for candidate in candidates:
|
||||
parts = candidate.split(".")
|
||||
dependencies.update(
|
||||
parent
|
||||
for index in range(2, len(parts))
|
||||
if (parent := ".".join(parts[:index])) in known_modules
|
||||
)
|
||||
if candidate in known_modules:
|
||||
dependencies.add(candidate)
|
||||
dependencies.discard(module_name)
|
||||
return dependencies
|
||||
|
||||
|
||||
def _strongly_connected_components(
|
||||
graph: dict[str, set[str]],
|
||||
) -> list[set[str]]:
|
||||
"""使用 Tarjan 算法返回依赖图中的非平凡强连通分量。"""
|
||||
indices: dict[str, int] = {}
|
||||
low_links: dict[str, int] = {}
|
||||
stack: list[str] = []
|
||||
on_stack: set[str] = set()
|
||||
components: list[set[str]] = []
|
||||
|
||||
def visit(module_name: str) -> None:
|
||||
"""深度遍历一个模块并在根节点处收集强连通分量。"""
|
||||
indices[module_name] = len(indices)
|
||||
low_links[module_name] = indices[module_name]
|
||||
stack.append(module_name)
|
||||
on_stack.add(module_name)
|
||||
for dependency in graph[module_name]:
|
||||
if dependency not in indices:
|
||||
visit(dependency)
|
||||
low_links[module_name] = min(
|
||||
low_links[module_name], low_links[dependency]
|
||||
)
|
||||
elif dependency in on_stack:
|
||||
low_links[module_name] = min(
|
||||
low_links[module_name], indices[dependency]
|
||||
)
|
||||
if low_links[module_name] != indices[module_name]:
|
||||
return
|
||||
component: set[str] = set()
|
||||
while stack:
|
||||
dependency = stack.pop()
|
||||
on_stack.remove(dependency)
|
||||
component.add(dependency)
|
||||
if dependency == module_name:
|
||||
break
|
||||
if len(component) > 1:
|
||||
components.append(component)
|
||||
|
||||
for module_name in sorted(graph):
|
||||
if module_name not in indices:
|
||||
visit(module_name)
|
||||
return components
|
||||
|
||||
|
||||
def _legacy_imports(path: Path) -> set[str]:
|
||||
"""提取源码中的静态和常量动态旧路径导入。"""
|
||||
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
|
||||
imports: set[str] = set()
|
||||
for node in ast.walk(tree):
|
||||
candidates: list[str] = []
|
||||
if isinstance(node, ast.Import):
|
||||
candidates.extend(alias.name for alias in node.names)
|
||||
elif isinstance(node, ast.ImportFrom) and node.module:
|
||||
candidates.append(node.module)
|
||||
elif isinstance(node, ast.Call) and node.args:
|
||||
argument = node.args[0]
|
||||
if isinstance(argument, ast.Constant) and isinstance(argument.value, str):
|
||||
candidates.append(argument.value)
|
||||
imports.update(
|
||||
candidate
|
||||
for candidate in candidates
|
||||
if candidate in LEGACY_MODULES or candidate.startswith(LEGACY_ROOTS)
|
||||
)
|
||||
return imports
|
||||
|
||||
|
||||
def test_legacy_roots_contain_no_python_sources():
|
||||
"""旧目录只能作为运行时虚拟包存在,仓库中不得重新出现源码。"""
|
||||
leftovers = sorted(
|
||||
str(path.relative_to(PROJECT_ROOT))
|
||||
for root_name in ("core", "helper", "utils")
|
||||
for path in (APP_ROOT / root_name).rglob("*.py")
|
||||
)
|
||||
assert leftovers == []
|
||||
|
||||
|
||||
def test_retired_canonical_filenames_do_not_return():
|
||||
"""能力包应使用包内语境明确的短文件名,避免再次出现冗余角色后缀。"""
|
||||
leftovers = [
|
||||
relative_path
|
||||
for relative_path in RETIRED_CANONICAL_FILES
|
||||
if (PROJECT_ROOT / relative_path).exists()
|
||||
]
|
||||
assert leftovers == []
|
||||
|
||||
|
||||
def test_host_code_does_not_import_legacy_roots():
|
||||
"""除插件和兼容层外,宿主代码必须使用 canonical 路径。"""
|
||||
violations: dict[str, set[str]] = {}
|
||||
for path in APP_ROOT.rglob("*.py"):
|
||||
relative = path.relative_to(APP_ROOT)
|
||||
if relative.parts[0] in {"compat", "plugins"}:
|
||||
continue
|
||||
imports = _legacy_imports(path)
|
||||
if imports:
|
||||
violations[str(relative)] = imports
|
||||
assert violations == {}
|
||||
|
||||
|
||||
def test_migrated_modules_are_not_in_import_cycles():
|
||||
"""任何 canonical 迁移模块都不得进入完整应用依赖图的环。"""
|
||||
modules = _discover_modules()
|
||||
known_modules = set(modules)
|
||||
graph = {
|
||||
name: _resolve_imports(name, path, known_modules)
|
||||
for name, path in modules.items()
|
||||
}
|
||||
relevant_cycles = [
|
||||
sorted(component)
|
||||
for component in _strongly_connected_components(graph)
|
||||
if any(name.startswith(CYCLE_ROOTS) for name in component)
|
||||
]
|
||||
assert relevant_cycles == []
|
||||
|
||||
|
||||
def test_canonical_layers_do_not_depend_on_sdk_or_compat():
|
||||
"""canonical 实现层不得反向依赖面向插件的 SDK 或兼容层。"""
|
||||
violations: dict[str, set[str]] = {}
|
||||
modules = _discover_modules()
|
||||
known_modules = set(modules)
|
||||
for module_name, path in modules.items():
|
||||
if not module_name.startswith(IMPLEMENTATION_ROOTS):
|
||||
continue
|
||||
dependencies = _resolve_imports(module_name, path, known_modules)
|
||||
forbidden = {
|
||||
dependency
|
||||
for dependency in dependencies
|
||||
if dependency.startswith(("app.sdk", "app.compat"))
|
||||
}
|
||||
if forbidden:
|
||||
violations[module_name] = forbidden
|
||||
assert violations == {}
|
||||
|
||||
|
||||
def test_capability_packages_do_not_import_forbidden_upper_layers():
|
||||
"""能力包只能依赖其明确允许的下层或同层协作包。"""
|
||||
modules = _discover_modules()
|
||||
known_modules = set(modules)
|
||||
violations: dict[str, set[str]] = {}
|
||||
for module_name, path in modules.items():
|
||||
parts = module_name.split(".")
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
source_package = parts[1]
|
||||
forbidden_packages = FORBIDDEN_CAPABILITY_IMPORTS.get(source_package)
|
||||
if not forbidden_packages:
|
||||
continue
|
||||
dependencies = _resolve_imports(module_name, path, known_modules)
|
||||
forbidden = {
|
||||
dependency
|
||||
for dependency in dependencies
|
||||
if len(dependency.split(".")) >= 2
|
||||
and dependency.split(".")[1] in forbidden_packages
|
||||
}
|
||||
if forbidden:
|
||||
violations[module_name] = forbidden
|
||||
assert violations == {}
|
||||
|
||||
|
||||
def test_platform_log_is_a_dependency_leaf():
|
||||
"""底层可引用平台日志,但日志模块本身不得反向导入应用模块。"""
|
||||
modules = _discover_modules()
|
||||
dependencies = _resolve_imports(
|
||||
"app.platform.log",
|
||||
modules["app.platform.log"],
|
||||
set(modules),
|
||||
)
|
||||
assert {
|
||||
dependency
|
||||
for dependency in dependencies
|
||||
if dependency.startswith("app.")
|
||||
} == set()
|
||||
|
||||
|
||||
def test_foundation_does_not_emit_runtime_logs():
|
||||
"""基础机制不初始化日志系统,运行期诊断由上层调用方负责。"""
|
||||
violations: list[str] = []
|
||||
for path in (APP_ROOT / "foundation").rglob("*.py"):
|
||||
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import) and any(
|
||||
alias.name == "logging" for alias in node.names
|
||||
):
|
||||
violations.append(str(path.relative_to(PROJECT_ROOT)))
|
||||
break
|
||||
if isinstance(node, ast.ImportFrom) and node.module in {
|
||||
"logging",
|
||||
"app.platform.log",
|
||||
}:
|
||||
violations.append(str(path.relative_to(PROJECT_ROOT)))
|
||||
break
|
||||
assert violations == []
|
||||
|
||||
|
||||
def test_cache_contract_does_not_import_concrete_adapters():
|
||||
"""平台缓存契约和内存机制不得反向导入基础设施适配器。"""
|
||||
modules = _discover_modules()
|
||||
dependencies = _resolve_imports(
|
||||
"app.platform.cache",
|
||||
modules["app.platform.cache"],
|
||||
set(modules),
|
||||
)
|
||||
assert {
|
||||
dependency
|
||||
for dependency in dependencies
|
||||
if dependency.startswith("app.infrastructure")
|
||||
} == set()
|
||||
|
||||
|
||||
def test_resource_adapter_does_not_restart_process():
|
||||
"""资源下载安装适配器不得反向调用进程重启能力。"""
|
||||
modules = _discover_modules()
|
||||
dependencies = _resolve_imports(
|
||||
"app.infrastructure.resource",
|
||||
modules["app.infrastructure.resource"],
|
||||
set(modules),
|
||||
)
|
||||
assert "app.platform.runtime" not in dependencies
|
||||
@@ -4,8 +4,8 @@ import time
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.utils import http as http_module
|
||||
from app.utils.http import AsyncRequestUtils
|
||||
from app.foundation import http as http_module
|
||||
from app.foundation.http import AsyncRequestUtils
|
||||
|
||||
PROXY = "http://proxy.example:7890"
|
||||
URL = "https://raw.githubusercontent.com/demo/repo/main/package.json"
|
||||
|
||||
@@ -3,14 +3,14 @@ from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
from app.chain.media import MediaChain
|
||||
from app.core.context import MusicInfo
|
||||
from app.core.meta.metamusic import (
|
||||
from app.domain.context import MusicInfo
|
||||
from app.domain.meta.metamusic import (
|
||||
audio_quality_score,
|
||||
audio_quality_tier,
|
||||
format_audio_quality,
|
||||
parse_audio_quality,
|
||||
)
|
||||
from app.helper.audio import AudioMetadataHelper
|
||||
from app.services.audio import AudioMetadataHelper
|
||||
from app.schemas.types import MUSIC_ENTITY_ALBUM
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ def test_read_audio_metadata_maps_easy_tags(monkeypatch):
|
||||
sample_rate=44100,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr("app.helper.audio.MutagenFile", lambda *_args, **_kwargs: audio)
|
||||
monkeypatch.setattr("app.services.audio.MutagenFile", lambda *_args, **_kwargs: audio)
|
||||
|
||||
meta = AudioMetadataHelper.read(Path("/music/08 - Get Lucky.flac"))
|
||||
|
||||
@@ -100,7 +100,7 @@ def test_music_info_serialization_exposes_derived_audio_quality():
|
||||
|
||||
def test_music_info_from_meta_preserves_track_and_audio_evidence():
|
||||
"""核心元数据转换应保留整理、刮削和通知依赖的曲序与实际音频参数。"""
|
||||
from app.core.meta import MetaMusic
|
||||
from app.domain.meta.metamusic import MetaMusic
|
||||
|
||||
info = MusicInfo.from_meta(MetaMusic(
|
||||
title="Get Lucky",
|
||||
@@ -142,7 +142,7 @@ def test_parse_compact_audio_quality_tokens_without_false_sample_bitrate():
|
||||
|
||||
def test_read_audio_metadata_falls_back_to_filename(monkeypatch):
|
||||
"""无法读取标签时应保留可用于手动整理的文件名元数据。"""
|
||||
monkeypatch.setattr("app.helper.audio.MutagenFile", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr("app.services.audio.MutagenFile", lambda *_args, **_kwargs: None)
|
||||
|
||||
meta = AudioMetadataHelper.read(Path("/music/Unknown Track.mp3"))
|
||||
|
||||
@@ -156,7 +156,7 @@ def test_read_audio_tags_does_not_fill_from_filename(monkeypatch):
|
||||
tags={"title": ["Tagged Title"]},
|
||||
info=SimpleNamespace(length=180),
|
||||
)
|
||||
monkeypatch.setattr("app.helper.audio.MutagenFile", lambda *_args, **_kwargs: audio)
|
||||
monkeypatch.setattr("app.services.audio.MutagenFile", lambda *_args, **_kwargs: audio)
|
||||
|
||||
meta = AudioMetadataHelper.read_tags(Path("/music/Daft Punk - Get Lucky 2013.flac"))
|
||||
|
||||
@@ -174,7 +174,7 @@ def test_read_audio_tags_ignores_invalid_musicbrainz_id(monkeypatch):
|
||||
},
|
||||
info=SimpleNamespace(length=180),
|
||||
)
|
||||
monkeypatch.setattr("app.helper.audio.MutagenFile", lambda *_args, **_kwargs: audio)
|
||||
monkeypatch.setattr("app.services.audio.MutagenFile", lambda *_args, **_kwargs: audio)
|
||||
|
||||
meta = AudioMetadataHelper.read_tags(Path("/music/track.flac"))
|
||||
|
||||
@@ -201,7 +201,7 @@ def test_read_audio_metadata_fallback_uses_dynamic_filename_parser(tmp_path, mon
|
||||
"S H E - S H E十七音乐会 2018 WEB-DL 1080P AVC AAC-FHDMv.flac"
|
||||
)
|
||||
audio_path.write_bytes(b"fake-flac")
|
||||
monkeypatch.setattr("app.helper.audio.MutagenFile", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr("app.services.audio.MutagenFile", lambda *_args, **_kwargs: None)
|
||||
|
||||
meta = AudioMetadataHelper.read(audio_path)
|
||||
|
||||
@@ -227,7 +227,7 @@ def test_read_audio_metadata_partial_tags_use_filename_for_missing_fields(
|
||||
sample_rate=44100,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr("app.helper.audio.MutagenFile", lambda *_args, **_kwargs: audio)
|
||||
monkeypatch.setattr("app.services.audio.MutagenFile", lambda *_args, **_kwargs: audio)
|
||||
|
||||
meta = AudioMetadataHelper.read(audio_path)
|
||||
|
||||
@@ -251,7 +251,7 @@ def test_read_audio_metadata_distinguishes_alac_inside_m4a(monkeypatch):
|
||||
sample_rate=96000,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr("app.helper.audio.MutagenFile", lambda *_args, **_kwargs: audio)
|
||||
monkeypatch.setattr("app.services.audio.MutagenFile", lambda *_args, **_kwargs: audio)
|
||||
|
||||
meta = AudioMetadataHelper.read(Path("/music/Lossless Track.m4a"))
|
||||
|
||||
@@ -276,7 +276,7 @@ def test_write_audio_metadata_maps_music_info_to_easy_tags(monkeypatch):
|
||||
self.saved = True
|
||||
|
||||
audio = FakeAudio()
|
||||
monkeypatch.setattr("app.helper.audio.MutagenFile", lambda *_args, **_kwargs: audio)
|
||||
monkeypatch.setattr("app.services.audio.MutagenFile", lambda *_args, **_kwargs: audio)
|
||||
|
||||
success = AudioMetadataHelper.write(
|
||||
Path("/music/08 - Get Lucky.flac"),
|
||||
@@ -319,7 +319,7 @@ def test_write_audio_metadata_does_not_write_album_id_as_recording_tag(monkeypat
|
||||
"""模拟 Mutagen 保存。"""
|
||||
|
||||
audio = FakeAudio()
|
||||
monkeypatch.setattr("app.helper.audio.MutagenFile", lambda *_args, **_kwargs: audio)
|
||||
monkeypatch.setattr("app.services.audio.MutagenFile", lambda *_args, **_kwargs: audio)
|
||||
|
||||
success = AudioMetadataHelper.write(
|
||||
Path("/music/Random Access Memories.flac"),
|
||||
@@ -338,7 +338,7 @@ def test_write_audio_metadata_does_not_write_album_id_as_recording_tag(monkeypat
|
||||
def test_write_audio_metadata_can_embed_cover_without_rewriting_tags(monkeypatch):
|
||||
"""音乐封面策略应能在标签策略关闭时独立执行。"""
|
||||
audio = SimpleNamespace(tags={"title": ["Original"]})
|
||||
monkeypatch.setattr("app.helper.audio.MutagenFile", lambda *_args, **_kwargs: audio)
|
||||
monkeypatch.setattr("app.services.audio.MutagenFile", lambda *_args, **_kwargs: audio)
|
||||
write_cover = Mock()
|
||||
monkeypatch.setattr(AudioMetadataHelper, "_write_cover", write_cover)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, Mock, patch
|
||||
import pytest
|
||||
|
||||
from app.chain.media import MediaChain
|
||||
from app.core.context import MediaInfo
|
||||
from app.domain.context import MediaInfo
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from unittest.mock import Mock
|
||||
from xml.dom import minidom
|
||||
|
||||
from app.core.meta import MetaBase
|
||||
from app.helper.scraper import MediaScraperHelper
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.domain.scraper import MediaScraperHelper
|
||||
from app.modules.bangumi import BangumiModule
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ def test_build_inputs_separates_video_and_music_domains():
|
||||
"title",
|
||||
"path",
|
||||
}
|
||||
assert all(not value.lower().endswith(tuple(benchmark.metainfo_module.settings.RMT_AUDIOEXT))
|
||||
assert all(not value.lower().endswith(benchmark.get_audio_extensions())
|
||||
for kind, value, _subtitle in music_once if kind == "music_query")
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ def test_parse_input_uses_public_production_entries(monkeypatch):
|
||||
|
||||
def test_selected_meta_parser_disables_and_restores_all_fast_paths(monkeypatch):
|
||||
"""Python 对照上下文应屏蔽影视和音乐 Rust 入口,并完整恢复原函数。"""
|
||||
rust_accel = benchmark.metainfo_module.rust_accel
|
||||
rust_accel = benchmark.rust_accel
|
||||
parser_names = (
|
||||
"parse_metainfo",
|
||||
"parse_metainfo_path",
|
||||
@@ -142,7 +142,7 @@ def test_video_projection_ignores_python_parser_internal_state():
|
||||
|
||||
def test_validate_rust_runtime_rejects_disabled_and_old_extensions(monkeypatch):
|
||||
"""运行前检查应拒绝关闭的 Rust 和缺少音乐入口的旧扩展。"""
|
||||
rust_accel = benchmark.metainfo_module.rust_accel
|
||||
rust_accel = benchmark.rust_accel
|
||||
monkeypatch.setattr(rust_accel, "is_enabled", Mock(return_value=False))
|
||||
|
||||
with pytest.raises(RuntimeError, match="未启用"):
|
||||
@@ -158,7 +158,7 @@ def test_validate_rust_runtime_rejects_disabled_and_old_extensions(monkeypatch):
|
||||
|
||||
def test_validate_rust_runtime_requires_successful_music_probe(monkeypatch):
|
||||
"""音乐 Rust 入口存在但实际回退 Python 时也必须拒绝执行基准。"""
|
||||
rust_accel = benchmark.metainfo_module.rust_accel
|
||||
rust_accel = benchmark.rust_accel
|
||||
extension = SimpleNamespace(parse_metamusic_fast=Mock())
|
||||
monkeypatch.setattr(rust_accel, "is_enabled", Mock(return_value=True))
|
||||
monkeypatch.setattr(rust_accel, "_moviepilot_rust", extension)
|
||||
|
||||
@@ -9,11 +9,11 @@ from app import schemas
|
||||
from app.chain.scraping import ScrapingChain
|
||||
from app.chain.storage import StorageChain
|
||||
from app.chain.transfer import TransferChain
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.event import Event
|
||||
from app.core.metainfo import MetaInfoPath
|
||||
from app.domain.context import MediaInfo
|
||||
from app.platform.events import Event
|
||||
from app.domain.metainfo import MetaInfoPath
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
from app.log import logger
|
||||
from app.platform.log import logger
|
||||
from app.schemas.types import EventType
|
||||
from tests.cases.files import bluray_files
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
from app.agent.tools.impl.browse_webpage import BrowserAction, BrowseWebpageTool
|
||||
from app.helper.browser import BrowserSessionHelper, PlaywrightHelper
|
||||
from app.infrastructure.browser import BrowserSessionHelper, PlaywrightHelper
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
@@ -168,7 +168,7 @@ def test_default_emulation_uses_cloakbrowser_context():
|
||||
page = _FakePage()
|
||||
context = _FakeContext([page])
|
||||
|
||||
with patch("app.helper.browser.settings.BROWSER_EMULATION", "cloakbrowser"), patch.object(
|
||||
with patch("app.infrastructure.browser.settings.BROWSER_EMULATION", "cloakbrowser"), patch.object(
|
||||
PlaywrightHelper,
|
||||
"_PlaywrightHelper__launch_cloakbrowser_context",
|
||||
return_value=context,
|
||||
@@ -197,7 +197,7 @@ def test_legacy_playwright_emulation_uses_cloakbrowser_context():
|
||||
page = _FakePage()
|
||||
context = _FakeContext([page])
|
||||
|
||||
with patch("app.helper.browser.settings.BROWSER_EMULATION", "Playwright"), patch.object(
|
||||
with patch("app.infrastructure.browser.settings.BROWSER_EMULATION", "Playwright"), patch.object(
|
||||
PlaywrightHelper,
|
||||
"_PlaywrightHelper__launch_cloakbrowser_context",
|
||||
return_value=context,
|
||||
|
||||
+18
-11
@@ -2,21 +2,24 @@ import asyncio
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from app.core.cache import (
|
||||
from app.infrastructure.cache import (
|
||||
AsyncFileBackend,
|
||||
AsyncMemoryBackend,
|
||||
AsyncRedisBackend,
|
||||
AsyncFileCache,
|
||||
FileBackend,
|
||||
RedisBackend,
|
||||
)
|
||||
from app.platform.cache import (
|
||||
AsyncFileCache,
|
||||
AsyncMemoryBackend,
|
||||
FileCache,
|
||||
MemoryBackend,
|
||||
RedisBackend,
|
||||
cached,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.helper.redis import AsyncRedisHelper, RedisHelper, serialize
|
||||
from app.platform.config import settings
|
||||
from app.infrastructure.redis import AsyncRedisHelper, RedisHelper, serialize
|
||||
|
||||
def test_file_backend_items_keep_relative_keys_and_bytes(tmp_path):
|
||||
"""
|
||||
@@ -142,7 +145,11 @@ def test_init_modules_does_not_clear_package_tool_cache(monkeypatch):
|
||||
monkeypatch.setattr(modules_initializer, "DisplayHelper", lambda: None)
|
||||
monkeypatch.setattr(modules_initializer, "DohHelper", lambda: None)
|
||||
monkeypatch.setattr(modules_initializer, "SitesHelper", lambda: None)
|
||||
monkeypatch.setattr(modules_initializer, "ResourceHelper", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
modules_initializer,
|
||||
"ResourceHelper",
|
||||
lambda: SimpleNamespace(check=lambda: False),
|
||||
)
|
||||
monkeypatch.setattr(modules_initializer, "user_auth", lambda: None)
|
||||
monkeypatch.setattr(modules_initializer, "ModuleManager", lambda: None)
|
||||
monkeypatch.setattr(modules_initializer.EventManager, "start", lambda self: None)
|
||||
@@ -535,8 +542,8 @@ def test_redis_helper_uses_blocking_pool_settings(monkeypatch):
|
||||
monkeypatch.setattr(settings, "CACHE_BACKEND_URL", "redis://cache:6379/2")
|
||||
monkeypatch.setattr(settings, "CACHE_REDIS_MAX_CONNECTIONS", 7)
|
||||
monkeypatch.setattr(settings, "CACHE_REDIS_POOL_TIMEOUT", 3)
|
||||
monkeypatch.setattr("app.helper.redis.redis.BlockingConnectionPool.from_url", fake_from_url)
|
||||
monkeypatch.setattr("app.helper.redis.redis.Redis", FakeClient)
|
||||
monkeypatch.setattr("app.infrastructure.redis.redis.BlockingConnectionPool.from_url", fake_from_url)
|
||||
monkeypatch.setattr("app.infrastructure.redis.redis.Redis", FakeClient)
|
||||
|
||||
helper = RedisHelper()
|
||||
helper.close()
|
||||
@@ -614,8 +621,8 @@ def test_async_redis_helper_uses_blocking_pool_settings(monkeypatch):
|
||||
monkeypatch.setattr(settings, "CACHE_BACKEND_URL", "redis://cache:6379/3")
|
||||
monkeypatch.setattr(settings, "CACHE_REDIS_MAX_CONNECTIONS", 9)
|
||||
monkeypatch.setattr(settings, "CACHE_REDIS_POOL_TIMEOUT", 4)
|
||||
monkeypatch.setattr("app.helper.redis.AsyncBlockingConnectionPool.from_url", fake_from_url)
|
||||
monkeypatch.setattr("app.helper.redis.Redis", FakeAsyncClient)
|
||||
monkeypatch.setattr("app.infrastructure.redis.AsyncBlockingConnectionPool.from_url", fake_from_url)
|
||||
monkeypatch.setattr("app.infrastructure.redis.Redis", FakeAsyncClient)
|
||||
|
||||
config_calls = asyncio.run(run_connect())
|
||||
|
||||
|
||||
@@ -46,8 +46,8 @@ def load_cli_module():
|
||||
app_module = ModuleType("app")
|
||||
core_module = ModuleType("app.core")
|
||||
helper_module = ModuleType("app.helper")
|
||||
config_module = ModuleType("app.core.config")
|
||||
system_module = ModuleType("app.helper.system")
|
||||
config_module = ModuleType("app.platform.config")
|
||||
system_module = ModuleType("app.platform.runtime")
|
||||
version_module = ModuleType("version")
|
||||
psutil_module = ModuleType("psutil")
|
||||
|
||||
@@ -68,8 +68,8 @@ def load_cli_module():
|
||||
"app": app_module,
|
||||
"app.core": core_module,
|
||||
"app.helper": helper_module,
|
||||
"app.core.config": config_module,
|
||||
"app.helper.system": system_module,
|
||||
"app.platform.config": config_module,
|
||||
"app.platform.runtime": system_module,
|
||||
"version": version_module,
|
||||
"psutil": psutil_module,
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import asyncio
|
||||
from typing import List
|
||||
from unittest import IsolatedAsyncioTestCase
|
||||
|
||||
from app.utils.coalesce import (
|
||||
from app.platform.coalesce import (
|
||||
CoalesceDecision,
|
||||
CoalesceSummary,
|
||||
EventCoalescer,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Any
|
||||
|
||||
from app.core.config import settings
|
||||
from app.platform.config import settings
|
||||
|
||||
|
||||
def test_update_float_setting_accepts_json_integer(monkeypatch) -> None:
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
from unittest import TestCase
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.core.meta.customization import CustomizationMatcher
|
||||
from app.domain.meta import customization as customization_module
|
||||
from app.domain.meta.customization import CustomizationMatcher
|
||||
|
||||
|
||||
class CustomizationMatcherTest(TestCase):
|
||||
def test_match_uses_latest_customization_setting(self):
|
||||
"""自定义占位符修改后,下一次识别应直接使用新配置。"""
|
||||
matcher = CustomizationMatcher()
|
||||
values = [["GROUP"], ["TEAM"]]
|
||||
def test_match_uses_latest_customization_setting(monkeypatch):
|
||||
"""自定义占位符修改后,下一次识别应直接使用新配置。"""
|
||||
matcher = CustomizationMatcher()
|
||||
values = [["GROUP"], ["TEAM"]]
|
||||
monkeypatch.setattr(
|
||||
customization_module,
|
||||
"_customization_provider",
|
||||
lambda: values[0],
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
matcher.systemconfig,
|
||||
"get",
|
||||
side_effect=lambda _: values[0],
|
||||
):
|
||||
self.assertEqual(matcher.match("[GROUP][TEAM] Movie"), "GROUP")
|
||||
values[0] = ["TEAM"]
|
||||
self.assertEqual(matcher.match("[GROUP][TEAM] Movie"), "TEAM")
|
||||
assert matcher.match("[GROUP][TEAM] Movie") == "GROUP"
|
||||
values[0] = ["TEAM"]
|
||||
assert matcher.match("[GROUP][TEAM] Movie") == "TEAM"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from app.db import SessionFactory
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
from app.utils import system as system_module
|
||||
from app.utils.system import SystemUtils
|
||||
from app.infrastructure import system as system_module
|
||||
from app.infrastructure.system import SystemUtils
|
||||
|
||||
|
||||
def test_dashboard_system_info_returns_runtime_environment(monkeypatch):
|
||||
|
||||
@@ -12,7 +12,7 @@ from app.db.models.downloadhistory import DownloadHistory, DownloadFiles
|
||||
from app.db.models.message import Message
|
||||
from app.db.models.siteuserdata import SiteUserData
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
from app.core.config import settings
|
||||
from app.platform.config import settings
|
||||
from app.scheduler import SchedulerChain
|
||||
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ from alembic.script import ScriptDirectory
|
||||
from sqlalchemy import inspect, text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.core.config import settings
|
||||
from app.platform.config import settings
|
||||
from app.db import Engine
|
||||
from app.db.init import init_db, update_db
|
||||
|
||||
|
||||
@@ -38,17 +38,17 @@ def _run_permission_case(tmp_path: Path, body: str, env: dict[str, str] | None =
|
||||
fake_bin = _write_fake_chown(tmp_path)
|
||||
chown_log = tmp_path / "chown.log"
|
||||
app_dir = tmp_path / "app"
|
||||
helper_dir = app_dir / "app" / "helper"
|
||||
resource_dir = app_dir / "app" / "infrastructure"
|
||||
public_dir = tmp_path / "public"
|
||||
home_dir = tmp_path / "home"
|
||||
(app_dir / "app" / "plugins").mkdir(parents=True)
|
||||
helper_dir.mkdir(parents=True)
|
||||
resource_dir.mkdir(parents=True)
|
||||
public_dir.mkdir()
|
||||
(home_dir / ".cloakbrowser").mkdir(parents=True)
|
||||
(home_dir / "runtime").mkdir()
|
||||
(app_dir / "app" / "plugins" / "plugin.py").write_text("# plugin\n", encoding="utf-8")
|
||||
(helper_dir / "user.sites.v3.bin").write_text("resources\n", encoding="utf-8")
|
||||
(helper_dir / "sites.cpython-312-x86_64-linux-gnu.so").write_text("plugin\n", encoding="utf-8")
|
||||
(resource_dir / "user.sites.v3.bin").write_text("resources\n", encoding="utf-8")
|
||||
(resource_dir / "sites.cpython-312-x86_64-linux-gnu.so").write_text("plugin\n", encoding="utf-8")
|
||||
(public_dir / "index.html").write_text("<!doctype html>\n", encoding="utf-8")
|
||||
(home_dir / ".cloakbrowser" / "chrome").write_text("browser cache\n", encoding="utf-8")
|
||||
(home_dir / "runtime" / "state").write_text("state\n", encoding="utf-8")
|
||||
@@ -64,7 +64,7 @@ def _run_permission_case(tmp_path: Path, body: str, env: dict[str, str] | None =
|
||||
"APP_DIR": str(app_dir),
|
||||
"PUBLIC_DIR": str(public_dir),
|
||||
"HOME_DIR": str(home_dir),
|
||||
"IMAGE_HELPER_DIR": str(helper_dir),
|
||||
"IMAGE_RESOURCE_DIR": str(resource_dir),
|
||||
"CONFIG_DIR": str(tmp_path / "config"),
|
||||
"PUID": str(os.getuid()),
|
||||
"PGID": str(os.getgid()),
|
||||
@@ -192,20 +192,20 @@ def test_runtime_writable_paths_are_still_corrected(tmp_path: Path) -> None:
|
||||
assert f"-R moviepilot:moviepilot {tmp_path}/home/runtime" in lines
|
||||
assert f"-R moviepilot:moviepilot {tmp_path}/config /var/lib/nginx /var/log/nginx" in lines
|
||||
assert "moviepilot:moviepilot /etc/hosts /tmp" in lines
|
||||
assert f"-R moviepilot:moviepilot {tmp_path}/app/app/helper" in lines
|
||||
assert f"-R moviepilot:moviepilot {tmp_path}/app/app/infrastructure" in lines
|
||||
assert not any(line.startswith("-R ") and ".cloakbrowser" in line for line in lines)
|
||||
assert not any(f"{tmp_path}/app " in line for line in lines)
|
||||
assert not any(f"{tmp_path}/public" in line for line in lines)
|
||||
|
||||
|
||||
def test_helper_resource_permissions_are_repaired_even_when_owner_matches(tmp_path: Path) -> None:
|
||||
def test_site_resource_permissions_are_repaired_even_when_owner_matches(tmp_path: Path) -> None:
|
||||
log = _run_permission_case(
|
||||
tmp_path,
|
||||
'correct_helper_resource_permissions',
|
||||
'correct_site_resource_permissions',
|
||||
)
|
||||
|
||||
lines = log.splitlines()
|
||||
assert f"-R moviepilot:moviepilot {tmp_path}/app/app/helper" in lines
|
||||
assert f"-R moviepilot:moviepilot {tmp_path}/app/app/infrastructure" in lines
|
||||
assert not any(line.startswith("-R ") and f"{tmp_path}/app " in line for line in lines)
|
||||
assert not any(line.startswith("-R ") and f"{tmp_path}/public" in line for line in lines)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.core.config import settings
|
||||
from app.platform.config import settings
|
||||
from app.doctor import checks, run_doctor
|
||||
from app.doctor.formatters import format_json_report, format_text_report
|
||||
from app.doctor.models import DoctorFinding, DoctorFindingStatus, DoctorSeverity
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import socket
|
||||
|
||||
from app.helper import doh
|
||||
from app.infrastructure import doh
|
||||
|
||||
|
||||
def test_doh_executor_is_lazy_and_shutdown_restores_socket(monkeypatch):
|
||||
|
||||
@@ -2,7 +2,7 @@ import asyncio
|
||||
from unittest.mock import Mock
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from app.core.meta import MetaBase
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.modules.douban import DoubanModule
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ import pytest
|
||||
|
||||
import app.chain.download as download_module
|
||||
from app.chain.download import DownloadChain
|
||||
from app.core.config import settings
|
||||
from app.core.context import Context, MediaInfo, SubtitleInfo, TorrentInfo
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.platform.config import settings
|
||||
from app.domain.context import Context, MediaInfo, SubtitleInfo, TorrentInfo
|
||||
from app.domain.metainfo import MetaInfo
|
||||
from app.schemas import DownloaderTorrent, FileItem, NotExistMediaInfo, TransferDirectoryConf
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
|
||||
@@ -138,7 +138,7 @@ def test_download_single_submits_download_added_to_background(monkeypatch):
|
||||
"""
|
||||
_FakeThreadHelper.submitted = []
|
||||
monkeypatch.setattr(
|
||||
"app.helper.directory.DirectoryHelper.get_download_dirs",
|
||||
"app.services.directory.DirectoryHelper.get_download_dirs",
|
||||
lambda _self: _download_dirs(),
|
||||
)
|
||||
monkeypatch.setattr(download_module, "ThreadHelper", _FakeThreadHelper)
|
||||
@@ -258,7 +258,7 @@ def test_download_single_persists_custom_words_snapshot(monkeypatch):
|
||||
|
||||
_FakeThreadHelper.submitted = []
|
||||
monkeypatch.setattr(
|
||||
"app.helper.directory.DirectoryHelper.get_download_dirs",
|
||||
"app.services.directory.DirectoryHelper.get_download_dirs",
|
||||
lambda _self: _download_dirs(),
|
||||
)
|
||||
monkeypatch.setattr(download_module, "ThreadHelper", _FakeThreadHelper)
|
||||
@@ -783,7 +783,7 @@ def test_download_single_records_failure_cooldown_when_downloader_rejects(monkey
|
||||
return SimpleNamespace(id=1)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.helper.directory.DirectoryHelper.get_download_dirs",
|
||||
"app.services.directory.DirectoryHelper.get_download_dirs",
|
||||
lambda _self: _download_dirs(),
|
||||
)
|
||||
monkeypatch.setattr(download_module, "TorrentHelper", _FakeTorrentHelper)
|
||||
|
||||
@@ -2,7 +2,7 @@ from types import SimpleNamespace
|
||||
|
||||
from app import schemas
|
||||
from app.api.endpoints import download as download_endpoint
|
||||
from app.core.context import MediaInfo
|
||||
from app.domain.context import MediaInfo
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
|
||||
|
||||
|
||||
@@ -11,9 +11,9 @@ import app.chain.download as download_module
|
||||
from app.agent.tools.impl.add_download_tasks import AddDownloadTasksTool
|
||||
from app.agent.tools.impl.update_download_tasks import UpdateDownloadTasksTool
|
||||
from app.chain.download import DownloadChain
|
||||
from app.core.context import Context, MediaInfo, SubtitleInfo, TorrentInfo
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.helper.directory import validate_download_save_path
|
||||
from app.domain.context import Context, MediaInfo, SubtitleInfo, TorrentInfo
|
||||
from app.domain.metainfo import MetaInfo
|
||||
from app.services.directory import validate_download_save_path
|
||||
from app.schemas import DownloaderTorrent, TransferDirectoryConf
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
|
||||
@@ -133,7 +133,7 @@ def _nested_download_dirs():
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_download_dirs(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"app.helper.directory.DirectoryHelper.get_download_dirs",
|
||||
"app.services.directory.DirectoryHelper.get_download_dirs",
|
||||
lambda _self: _download_dirs(),
|
||||
)
|
||||
|
||||
@@ -158,7 +158,7 @@ def test_validate_download_save_path_accepts_legacy_remote_path_without_storage_
|
||||
def test_validate_download_save_path_prefers_configured_local_root(monkeypatch):
|
||||
"""无前缀路径同时命中本地和远程根目录时应保持本地语义。"""
|
||||
monkeypatch.setattr(
|
||||
"app.helper.directory.DirectoryHelper.get_download_dirs",
|
||||
"app.services.directory.DirectoryHelper.get_download_dirs",
|
||||
lambda _self: [
|
||||
TransferDirectoryConf(
|
||||
name="远程下载",
|
||||
@@ -191,7 +191,7 @@ def test_validate_download_save_path_accepts_windows_configured_root_and_childre
|
||||
expected,
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
"app.helper.directory.DirectoryHelper.get_download_dirs",
|
||||
"app.services.directory.DirectoryHelper.get_download_dirs",
|
||||
lambda _self: _windows_download_dirs(),
|
||||
)
|
||||
|
||||
@@ -213,7 +213,7 @@ def test_validate_download_save_path_rejects_windows_paths_outside_configured_ro
|
||||
save_path,
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
"app.helper.directory.DirectoryHelper.get_download_dirs",
|
||||
"app.services.directory.DirectoryHelper.get_download_dirs",
|
||||
lambda _self: _windows_download_dirs(),
|
||||
)
|
||||
|
||||
@@ -255,7 +255,7 @@ def _build_tv_media() -> MediaInfo:
|
||||
|
||||
def test_resolve_media_download_dir_applies_configured_root_classification(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"app.helper.directory.DirectoryHelper.get_download_dirs",
|
||||
"app.services.directory.DirectoryHelper.get_download_dirs",
|
||||
lambda _self: _classified_download_dirs(),
|
||||
)
|
||||
|
||||
@@ -271,7 +271,7 @@ def test_resolve_media_download_dir_applies_configured_root_classification(monke
|
||||
|
||||
def test_resolve_media_download_dir_keeps_configured_child_path_exact(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"app.helper.directory.DirectoryHelper.get_download_dirs",
|
||||
"app.services.directory.DirectoryHelper.get_download_dirs",
|
||||
lambda _self: _classified_download_dirs(),
|
||||
)
|
||||
|
||||
@@ -287,7 +287,7 @@ def test_resolve_media_download_dir_keeps_configured_child_path_exact(monkeypatc
|
||||
|
||||
def test_resolve_media_download_dir_applies_remote_root_classification(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"app.helper.directory.DirectoryHelper.get_download_dirs",
|
||||
"app.services.directory.DirectoryHelper.get_download_dirs",
|
||||
lambda _self: _classified_download_dirs(),
|
||||
)
|
||||
|
||||
@@ -304,7 +304,7 @@ def test_resolve_media_download_dir_applies_remote_root_classification(monkeypat
|
||||
def test_resolve_media_download_dir_accepts_legacy_remote_root_without_storage_prefix(monkeypatch):
|
||||
"""订阅中的旧版远程根路径应按对应存储和分类配置解析。"""
|
||||
monkeypatch.setattr(
|
||||
"app.helper.directory.DirectoryHelper.get_download_dirs",
|
||||
"app.services.directory.DirectoryHelper.get_download_dirs",
|
||||
lambda _self: _classified_download_dirs(),
|
||||
)
|
||||
|
||||
@@ -320,7 +320,7 @@ def test_resolve_media_download_dir_accepts_legacy_remote_root_without_storage_p
|
||||
|
||||
def test_resolve_media_download_dir_uses_matching_media_specific_root(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"app.helper.directory.DirectoryHelper.get_download_dirs",
|
||||
"app.services.directory.DirectoryHelper.get_download_dirs",
|
||||
lambda _self: _media_specific_download_dirs(),
|
||||
)
|
||||
|
||||
@@ -348,7 +348,7 @@ def test_resolve_media_download_dir_uses_exact_nested_root_configuration(
|
||||
expected,
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
"app.helper.directory.DirectoryHelper.get_download_dirs",
|
||||
"app.services.directory.DirectoryHelper.get_download_dirs",
|
||||
lambda _self: _nested_download_dirs(),
|
||||
)
|
||||
|
||||
@@ -429,7 +429,7 @@ def test_download_single_rejects_event_overridden_bad_save_path_before_downloade
|
||||
def test_download_single_applies_configured_root_classification(monkeypatch):
|
||||
monkeypatch.setattr(download_module.eventmanager, "send_event", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
"app.helper.directory.DirectoryHelper.get_download_dirs",
|
||||
"app.services.directory.DirectoryHelper.get_download_dirs",
|
||||
lambda _self: _classified_download_dirs(),
|
||||
)
|
||||
chain = _build_download_chain()
|
||||
@@ -450,7 +450,7 @@ def test_download_single_accepts_legacy_remote_root_without_storage_prefix(monke
|
||||
"""旧订阅的无前缀远程根应以正确 FileURI 提交给下载模块。"""
|
||||
monkeypatch.setattr(download_module.eventmanager, "send_event", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
"app.helper.directory.DirectoryHelper.get_download_dirs",
|
||||
"app.services.directory.DirectoryHelper.get_download_dirs",
|
||||
lambda _self: _classified_download_dirs(),
|
||||
)
|
||||
chain = _build_download_chain()
|
||||
|
||||
@@ -13,12 +13,12 @@ def _load_downloader_base():
|
||||
app_module.__path__ = []
|
||||
helper_module = types.ModuleType("app.helper")
|
||||
helper_module.__path__ = []
|
||||
service_module = types.ModuleType("app.helper.service")
|
||||
service_module = types.ModuleType("app.extensions.service_registry")
|
||||
schemas_module = types.ModuleType("app.schemas")
|
||||
schema_types_module = types.ModuleType("app.schemas.types")
|
||||
utils_module = types.ModuleType("app.utils")
|
||||
utils_module.__path__ = []
|
||||
mixins_module = types.ModuleType("app.utils.mixins")
|
||||
mixins_module = types.ModuleType("app.platform.reload")
|
||||
|
||||
class StorageSchema(Enum):
|
||||
Local = "local"
|
||||
@@ -75,11 +75,11 @@ def _load_downloader_base():
|
||||
stub_modules = {
|
||||
"app": app_module,
|
||||
"app.helper": helper_module,
|
||||
"app.helper.service": service_module,
|
||||
"app.extensions.service_registry": service_module,
|
||||
"app.schemas": schemas_module,
|
||||
"app.schemas.types": schema_types_module,
|
||||
"app.utils": utils_module,
|
||||
"app.utils.mixins": mixins_module,
|
||||
"app.platform.reload": mixins_module,
|
||||
}
|
||||
|
||||
module_path = repo_root / "app" / "modules" / "__init__.py"
|
||||
@@ -101,7 +101,7 @@ def _load_transmission_module():
|
||||
app_module.__path__ = []
|
||||
core_module = types.ModuleType("app.core")
|
||||
core_module.__path__ = []
|
||||
cache_module = types.ModuleType("app.core.cache")
|
||||
cache_module = types.ModuleType("app.platform.cache")
|
||||
modules_module = types.ModuleType("app.modules")
|
||||
modules_module.__path__ = []
|
||||
transmission_package_module = types.ModuleType("app.modules.transmission")
|
||||
@@ -109,12 +109,12 @@ def _load_transmission_module():
|
||||
transmission_client_module = types.ModuleType("app.modules.transmission.transmission")
|
||||
schemas_module = types.ModuleType("app.schemas")
|
||||
schema_types_module = types.ModuleType("app.schemas.types")
|
||||
config_module = types.ModuleType("app.core.config")
|
||||
metainfo_module = types.ModuleType("app.core.metainfo")
|
||||
log_module = types.ModuleType("app.log")
|
||||
config_module = types.ModuleType("app.platform.config")
|
||||
metainfo_module = types.ModuleType("app.domain.metainfo")
|
||||
log_module = types.ModuleType("app.platform.log")
|
||||
utils_module = types.ModuleType("app.utils")
|
||||
utils_module.__path__ = []
|
||||
string_module = types.ModuleType("app.utils.string")
|
||||
string_module = types.ModuleType("app.domain.string")
|
||||
transmission_rpc_module = types.ModuleType("transmission_rpc")
|
||||
torrentool_module = types.ModuleType("torrentool")
|
||||
torrentool_module.__path__ = []
|
||||
@@ -233,17 +233,17 @@ def _load_transmission_module():
|
||||
stub_modules = {
|
||||
"app": app_module,
|
||||
"app.core": core_module,
|
||||
"app.core.cache": cache_module,
|
||||
"app.core.config": config_module,
|
||||
"app.core.metainfo": metainfo_module,
|
||||
"app.log": log_module,
|
||||
"app.platform.cache": cache_module,
|
||||
"app.platform.config": config_module,
|
||||
"app.domain.metainfo": metainfo_module,
|
||||
"app.platform.log": log_module,
|
||||
"app.modules": modules_module,
|
||||
"app.modules.transmission": transmission_package_module,
|
||||
"app.modules.transmission.transmission": transmission_client_module,
|
||||
"app.schemas": schemas_module,
|
||||
"app.schemas.types": schema_types_module,
|
||||
"app.utils": utils_module,
|
||||
"app.utils.string": string_module,
|
||||
"app.domain.string": string_module,
|
||||
"transmission_rpc": transmission_rpc_module,
|
||||
"torrentool": torrentool_module,
|
||||
"torrentool.torrent": torrentool_torrent_module,
|
||||
|
||||
@@ -3,7 +3,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from app.chain.transfer import TransferChain
|
||||
from app.helper.format import EpisodeFormatRuleHelper, FormatParser, _AutoRecommendSample
|
||||
from app.services.formatting import EpisodeFormatRuleHelper, FormatParser, _AutoRecommendSample
|
||||
from app.schemas import EpisodeFormatRule, FileItem
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ def _make_file(name: str, size: int = 150 * 1024 * 1024) -> FileItem:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_media_exts(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"app.helper.format.settings.RMT_MEDIAEXT",
|
||||
"app.services.formatting.settings.RMT_MEDIAEXT",
|
||||
[".mkv", ".mp4"],
|
||||
)
|
||||
|
||||
@@ -233,7 +233,7 @@ def test_auto_recommend_returns_false_when_parse_raises(monkeypatch):
|
||||
def _raise_parse(*args, **kwargs):
|
||||
raise ValueError("broken parse")
|
||||
|
||||
monkeypatch.setattr("app.helper.format._match_template", _raise_parse)
|
||||
monkeypatch.setattr("app.services.formatting._match_template", _raise_parse)
|
||||
|
||||
state, errmsg, data = helper.recommend([], samples)
|
||||
|
||||
@@ -432,7 +432,7 @@ def test_auto_recommend_uses_native_episode_as_fallback(monkeypatch):
|
||||
]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.helper.format.anitopy.parse",
|
||||
"app.services.formatting.anitopy.parse",
|
||||
lambda _: {},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
@@ -508,7 +508,7 @@ def test_auto_recommend_prefers_bracket_episode_over_title_sequence_native(monke
|
||||
]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.helper.format.anitopy.parse",
|
||||
"app.services.formatting.anitopy.parse",
|
||||
lambda _: {},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
@@ -541,7 +541,7 @@ def test_auto_recommend_corrects_anitopy_title_sequence_bias(monkeypatch):
|
||||
return {"episode_number": "3"}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.helper.format.anitopy.parse",
|
||||
"app.services.formatting.anitopy.parse",
|
||||
_mock_parse,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
@@ -611,7 +611,7 @@ def test_extract_episode_with_native_fallback_keeps_anitopy_range_list(monkeypat
|
||||
item = _make_file("Show - 01-02 [02].mkv")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.helper.format.anitopy.parse",
|
||||
"app.services.formatting.anitopy.parse",
|
||||
lambda _: {"episode_number": ["01", "02"]},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
|
||||
@@ -9,8 +9,8 @@ setattr(sys.modules["transmission_rpc"], "File", object)
|
||||
sys.modules.setdefault("psutil", ModuleType("psutil"))
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.meta import MetaBase
|
||||
from app.domain.context import MediaInfo
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.event import Event, EventManager
|
||||
from app.core.plugin import PluginManager
|
||||
from app.platform.events import Event, EventManager
|
||||
from app.extensions.plugin_manager import PluginManager
|
||||
from app.schemas.types import ChainEventType
|
||||
|
||||
|
||||
@@ -48,10 +48,8 @@ async def test_plugin_event_error_uses_public_display_name(monkeypatch):
|
||||
event_manager._EventManager__invoke_handler_by_type_sync(
|
||||
FailingDiscoverPlugin.handle, event
|
||||
)
|
||||
await event_manager._EventManager__invoke_plugin_method_async(
|
||||
plugin_manager,
|
||||
FailingDiscoverPlugin.__name__,
|
||||
"handle",
|
||||
await event_manager._EventManager__invoke_handler_by_type_async(
|
||||
FailingDiscoverPlugin.handle,
|
||||
event,
|
||||
)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.config import settings
|
||||
from app.platform.config import settings
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parents[1] / "skills" / "feedback-issue" / "scripts"
|
||||
|
||||
@@ -12,7 +12,7 @@ from unittest.mock import patch
|
||||
from urllib.parse import quote
|
||||
|
||||
from app.agent.tools.factory import MoviePilotToolFactory
|
||||
from app.core.config import settings
|
||||
from app.platform.config import settings
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parents[1] / "skills" / "feedback-issue" / "scripts"
|
||||
|
||||
@@ -6,7 +6,7 @@ ensure_optional_stub("psutil")
|
||||
ensure_optional_stub("dateparser")
|
||||
ensure_optional_stub("Pinyin2Hanzi", is_pinyin=lambda value: False)
|
||||
|
||||
from app.core.context import MediaInfo
|
||||
from app.domain.context import MediaInfo
|
||||
from app.modules.feishu.feishu import Feishu
|
||||
from app.schemas import Notification
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.core.context import MediaInfo
|
||||
from app.domain.context import MediaInfo
|
||||
from app.modules.filemanager import FileManagerModule
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.utils.http import RequestUtils
|
||||
from app.foundation.http import RequestUtils
|
||||
|
||||
|
||||
def test_xml_decoding_prefers_xml_declaration_over_http_default():
|
||||
|
||||
@@ -18,13 +18,13 @@ class JellyfinUserResolutionTest(unittest.TestCase):
|
||||
def test_loader_does_not_leave_stub_modules_in_sys_modules(self):
|
||||
self.assertNotIn("_test_jellyfin_module", sys.modules)
|
||||
self.assertFalse(
|
||||
getattr(sys.modules.get("app.log"), "_jellyfin_test_stub", False)
|
||||
getattr(sys.modules.get("app.platform.log"), "_jellyfin_test_stub", False)
|
||||
)
|
||||
self.assertFalse(
|
||||
getattr(sys.modules.get("app.core.config"), "_jellyfin_test_stub", False)
|
||||
getattr(sys.modules.get("app.platform.config"), "_jellyfin_test_stub", False)
|
||||
)
|
||||
self.assertFalse(
|
||||
getattr(sys.modules.get("app.utils.http"), "_jellyfin_test_stub", False)
|
||||
getattr(sys.modules.get("app.foundation.http"), "_jellyfin_test_stub", False)
|
||||
)
|
||||
|
||||
def _build_client(self) -> Jellyfin:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from app.utils.jieba import cut
|
||||
from app.foundation.jieba import cut
|
||||
|
||||
|
||||
def test_cut_accepts_legacy_hmm_argument():
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import builtins
|
||||
import importlib
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.compat.diagnostics import (
|
||||
configure_legacy_import_diagnostics,
|
||||
get_legacy_import_diagnostics,
|
||||
reset_legacy_import_diagnostics,
|
||||
scan_plugin_legacy_imports,
|
||||
)
|
||||
from app.compat.imports import install_legacy_import_hook
|
||||
from app.compat.manifest import (
|
||||
MODULE_ALIASES,
|
||||
PACKAGE_ALIASES,
|
||||
PACKAGE_EXPORTS,
|
||||
VIRTUAL_PACKAGES,
|
||||
ModuleAlias,
|
||||
)
|
||||
|
||||
|
||||
LEGACY_PACKAGE = "legacy_compat_test"
|
||||
LEGACY_MODULE = f"{LEGACY_PACKAGE}.target"
|
||||
CANONICAL_PACKAGE = "canonical_compat_test"
|
||||
CANONICAL_MODULE = f"{CANONICAL_PACKAGE}.target"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def compatibility_modules(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
"""创建可计数初始化次数的临时 canonical 模块和旧路径映射。"""
|
||||
package_dir = tmp_path / CANONICAL_PACKAGE
|
||||
package_dir.mkdir()
|
||||
(package_dir / "__init__.py").write_text("", encoding="utf-8")
|
||||
(package_dir / "target.py").write_text(
|
||||
"import builtins\n"
|
||||
"builtins._legacy_compat_test_count = "
|
||||
"getattr(builtins, '_legacy_compat_test_count', 0) + 1\n"
|
||||
"TOKEN = object()\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.syspath_prepend(str(tmp_path))
|
||||
monkeypatch.setitem(
|
||||
MODULE_ALIASES,
|
||||
LEGACY_MODULE,
|
||||
ModuleAlias(
|
||||
target=CANONICAL_MODULE,
|
||||
replacement="app.sdk.test",
|
||||
introduced="test",
|
||||
owner="test",
|
||||
),
|
||||
)
|
||||
VIRTUAL_PACKAGES.add(LEGACY_PACKAGE)
|
||||
install_legacy_import_hook()
|
||||
reset_legacy_import_diagnostics()
|
||||
yield
|
||||
MODULE_ALIASES.pop(LEGACY_MODULE, None)
|
||||
VIRTUAL_PACKAGES.discard(LEGACY_PACKAGE)
|
||||
for module_name in (LEGACY_MODULE, LEGACY_PACKAGE, CANONICAL_MODULE, CANONICAL_PACKAGE):
|
||||
sys.modules.pop(module_name, None)
|
||||
if hasattr(builtins, "_legacy_compat_test_count"):
|
||||
delattr(builtins, "_legacy_compat_test_count")
|
||||
reset_legacy_import_diagnostics()
|
||||
|
||||
|
||||
def test_legacy_import_reuses_canonical_module_identity(compatibility_modules):
|
||||
"""旧路径先导入时应复用 canonical 模块且只执行一次源码。"""
|
||||
legacy = importlib.import_module(LEGACY_MODULE)
|
||||
canonical = importlib.import_module(CANONICAL_MODULE)
|
||||
|
||||
assert legacy is canonical
|
||||
assert sys.modules[LEGACY_MODULE] is sys.modules[CANONICAL_MODULE]
|
||||
assert legacy.__name__ == CANONICAL_MODULE
|
||||
assert legacy.__spec__.name == CANONICAL_MODULE
|
||||
assert builtins._legacy_compat_test_count == 1
|
||||
|
||||
|
||||
def test_canonical_first_import_keeps_same_legacy_identity(compatibility_modules):
|
||||
"""canonical 路径先导入时,旧路径仍应绑定同一模块对象。"""
|
||||
canonical = importlib.import_module(CANONICAL_MODULE)
|
||||
legacy = importlib.import_module(LEGACY_MODULE)
|
||||
|
||||
assert legacy is canonical
|
||||
assert legacy.TOKEN is canonical.TOKEN
|
||||
assert builtins._legacy_compat_test_count == 1
|
||||
|
||||
|
||||
def test_virtual_package_blocks_unregistered_descendants(compatibility_modules):
|
||||
"""合成旧父包不得向其他 Finder 泄漏未登记的子模块。"""
|
||||
package = importlib.import_module(LEGACY_PACKAGE)
|
||||
|
||||
assert package.__path__ == []
|
||||
with pytest.raises(ModuleNotFoundError, match="未在兼容映射中登记"):
|
||||
importlib.import_module(f"{LEGACY_PACKAGE}.unknown")
|
||||
|
||||
|
||||
def test_debug_diagnostics_warn_once_for_runtime_alias(compatibility_modules):
|
||||
"""DEBUG 运行时兼容命中应输出一次包含迁移目标的警告。"""
|
||||
messages = []
|
||||
configure_legacy_import_diagnostics(enabled=True, emitter=messages.append)
|
||||
|
||||
importlib.import_module(LEGACY_MODULE)
|
||||
importlib.import_module(LEGACY_MODULE)
|
||||
|
||||
assert len(messages) == 1
|
||||
assert LEGACY_MODULE in messages[0]
|
||||
assert CANONICAL_MODULE in messages[0]
|
||||
assert "app.sdk.test" in messages[0]
|
||||
|
||||
|
||||
def test_production_diagnostics_stay_silent(compatibility_modules):
|
||||
"""DEBUG 关闭时兼容导入继续生效但不输出告警。"""
|
||||
messages = []
|
||||
configure_legacy_import_diagnostics(enabled=False, emitter=messages.append)
|
||||
|
||||
module = importlib.import_module(LEGACY_MODULE)
|
||||
|
||||
assert module is importlib.import_module(CANONICAL_MODULE)
|
||||
assert messages == []
|
||||
|
||||
|
||||
def test_plugin_scan_reports_cached_legacy_import(compatibility_modules, tmp_path: Path):
|
||||
"""模块已缓存时,插件 AST 扫描仍应报告其静态旧导入。"""
|
||||
importlib.import_module(LEGACY_MODULE)
|
||||
reset_legacy_import_diagnostics()
|
||||
messages = []
|
||||
configure_legacy_import_diagnostics(enabled=True, emitter=messages.append)
|
||||
plugin_dir = tmp_path / "sampleplugin"
|
||||
plugin_dir.mkdir()
|
||||
(plugin_dir / "__init__.py").write_text(
|
||||
f"from {LEGACY_MODULE} import TOKEN\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
scan_plugin_legacy_imports("SamplePlugin", plugin_dir)
|
||||
scan_plugin_legacy_imports("SamplePlugin", plugin_dir)
|
||||
|
||||
assert len(messages) == 1
|
||||
assert "插件 sampleplugin" in messages[0]
|
||||
assert "__init__.py:1" in messages[0]
|
||||
snapshot = get_legacy_import_diagnostics()
|
||||
assert ("app.plugins.sampleplugin", LEGACY_MODULE) in snapshot["reported"]
|
||||
|
||||
|
||||
def test_plugin_scan_accepts_utf8_bom(compatibility_modules, tmp_path: Path):
|
||||
"""插件源码带 UTF-8 BOM 时仍应识别旧导入并输出迁移警告。"""
|
||||
messages = []
|
||||
configure_legacy_import_diagnostics(enabled=True, emitter=messages.append)
|
||||
plugin_dir = tmp_path / "bomplugin"
|
||||
plugin_dir.mkdir()
|
||||
source = f"from {LEGACY_MODULE} import TOKEN\n".encode("utf-8")
|
||||
(plugin_dir / "__init__.py").write_bytes(b"\xef\xbb\xbf" + source)
|
||||
|
||||
scan_plugin_legacy_imports("BomPlugin", plugin_dir)
|
||||
|
||||
assert len(messages) == 1
|
||||
assert "插件 bomplugin" in messages[0]
|
||||
assert LEGACY_MODULE in messages[0]
|
||||
|
||||
|
||||
def test_manifest_aliases_reuse_real_canonical_modules():
|
||||
"""正式映射表中的旧路径应在隔离进程中复用全部 canonical 模块。"""
|
||||
code = """
|
||||
import importlib
|
||||
from app.compat.manifest import MODULE_ALIASES
|
||||
|
||||
for legacy_name, alias in MODULE_ALIASES.items():
|
||||
try:
|
||||
canonical = importlib.import_module(alias.target)
|
||||
except ModuleNotFoundError:
|
||||
if alias.target == "app.infrastructure.sites":
|
||||
continue
|
||||
raise
|
||||
legacy = importlib.import_module(legacy_name)
|
||||
assert legacy is canonical, (legacy_name, alias.target)
|
||||
assert legacy.__name__ == alias.target, legacy_name
|
||||
assert legacy.__spec__.name == alias.target, legacy_name
|
||||
"""
|
||||
subprocess.run(
|
||||
[sys.executable, "-c", code],
|
||||
cwd=Path(__file__).parents[1],
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
def test_virtual_package_exports_resolve_exact_manifest_symbols():
|
||||
"""合成旧包仅公开 manifest 声明的符号,并记录 DEBUG 兼容警告。"""
|
||||
legacy_package = "app.core.meta"
|
||||
sys.modules.pop(legacy_package, None)
|
||||
messages = []
|
||||
configure_legacy_import_diagnostics(enabled=True, emitter=messages.append)
|
||||
|
||||
package = importlib.import_module(legacy_package)
|
||||
|
||||
assert set(package.__all__) == set(PACKAGE_EXPORTS[legacy_package])
|
||||
assert package.MetaBase is importlib.import_module(
|
||||
"app.domain.meta.metabase"
|
||||
).MetaBase
|
||||
assert PACKAGE_ALIASES[legacy_package].replacement in messages[0]
|
||||
reset_legacy_import_diagnostics()
|
||||
@@ -7,7 +7,7 @@ import pytest
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.startup import lifecycle, modules_initializer
|
||||
from app.utils import http as http_utils
|
||||
from app.foundation import http as http_utils
|
||||
|
||||
|
||||
def _assert_completed_once(mock: MagicMock) -> None:
|
||||
@@ -227,7 +227,7 @@ def test_restart_endpoint_failure_preserves_stop_state(
|
||||
def test_command_restart_failure_does_not_publish_stop_request(monkeypatch):
|
||||
"""命令重启失败时进程仍在运行,不能提前发布停止请求"""
|
||||
from app.chain.system import SystemChain
|
||||
from app.core.config import global_vars
|
||||
from app.platform.config import global_vars
|
||||
|
||||
stop_event = threading.Event()
|
||||
monkeypatch.setattr(global_vars, "STOP_EVENT", stop_event)
|
||||
@@ -320,9 +320,6 @@ def test_shared_http_close_waits_for_real_lru_eviction(monkeypatch):
|
||||
|
||||
monkeypatch.setattr(http_utils, "_MAX_SHARED_TRANSPORTS_PER_LOOP", 1)
|
||||
monkeypatch.setattr(http_utils.httpx, "AsyncHTTPTransport", FakeTransport)
|
||||
debug = MagicMock()
|
||||
monkeypatch.setattr(http_utils.logger, "debug", debug)
|
||||
|
||||
async def run_test():
|
||||
transport_kwargs = {
|
||||
"proxy": None,
|
||||
@@ -358,6 +355,7 @@ def test_shared_http_close_waits_for_real_lru_eviction(monkeypatch):
|
||||
await close_task
|
||||
await asyncio.sleep(0)
|
||||
assert eviction_tasks[0].done()
|
||||
assert isinstance(eviction_tasks[0].exception(), RuntimeError)
|
||||
assert evicted_transport.closed
|
||||
assert active_transport.closed
|
||||
with http_utils._shared_async_transports_lock:
|
||||
@@ -373,12 +371,6 @@ def test_shared_http_close_waits_for_real_lru_eviction(monkeypatch):
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
debug.assert_any_call(
|
||||
"LRU 淘汰共享 transport 时关闭失败: "
|
||||
"RuntimeError('eviction close failed')"
|
||||
)
|
||||
|
||||
|
||||
def test_shared_http_close_ignores_eviction_from_other_loop():
|
||||
"""当前事件循环关闭不能等待其他循环持有的淘汰任务"""
|
||||
ready = threading.Event()
|
||||
|
||||
@@ -120,8 +120,8 @@ def _build_fake_openai_modules(chat_openai_cls=_FakeChatOpenAIForPatch):
|
||||
|
||||
|
||||
# 以假 settings/log 控制 helper 加载期行为;用唯一模块名加载,并以 stub_modules 上下文
|
||||
# 在 import 期注入、退出后还原真实 app.core.config / app.log,避免污染其他测试。
|
||||
_config_stub = ModuleType("app.core.config")
|
||||
# 在 import 期注入、退出后还原真实平台配置与日志模块,避免污染其他测试。
|
||||
_config_stub = ModuleType("app.platform.config")
|
||||
_config_stub.settings = SimpleNamespace(
|
||||
LLM_PROVIDER="global-provider",
|
||||
LLM_MODEL="global-model",
|
||||
@@ -136,11 +136,11 @@ _config_stub.settings = SimpleNamespace(
|
||||
LLM_USE_PROXY=True,
|
||||
PROXY_HOST=None,
|
||||
)
|
||||
_log_stub = ModuleType("app.log")
|
||||
_log_stub = ModuleType("app.platform.log")
|
||||
_log_stub.logger = _DummyLogger()
|
||||
|
||||
module_path = Path(__file__).resolve().parents[1] / "app" / "agent" / "llm" / "helper.py"
|
||||
with stub_modules({"app.core.config": _config_stub, "app.log": _log_stub}):
|
||||
with stub_modules({"app.platform.config": _config_stub, "app.platform.log": _log_stub}):
|
||||
spec = importlib.util.spec_from_file_location("test_llm_module", module_path)
|
||||
llm_module = importlib.util.module_from_spec(spec)
|
||||
assert spec and spec.loader
|
||||
|
||||
@@ -29,7 +29,7 @@ class LocalSetupUninstallTests(unittest.TestCase):
|
||||
|
||||
temp_path = Path(temp_dir.name)
|
||||
root_dir = temp_path / "MoviePilot"
|
||||
helper_dir = root_dir / "app" / "helper"
|
||||
resource_dir = root_dir / "app" / "infrastructure"
|
||||
runtime_dir = root_dir / ".runtime"
|
||||
public_dir = root_dir / "public"
|
||||
venv_dir = root_dir / "venv"
|
||||
@@ -37,15 +37,15 @@ class LocalSetupUninstallTests(unittest.TestCase):
|
||||
config_dir = root_dir / "config" if legacy_config else temp_path / "moviepilot-config"
|
||||
temp_config_dir = config_dir / "temp"
|
||||
|
||||
helper_dir.mkdir(parents=True)
|
||||
resource_dir.mkdir(parents=True)
|
||||
runtime_dir.mkdir(parents=True)
|
||||
public_dir.mkdir(parents=True)
|
||||
venv_dir.mkdir(parents=True)
|
||||
temp_config_dir.mkdir(parents=True)
|
||||
install_env_file.write_text("CONFIG_DIR=/tmp/moviepilot-config\n", encoding="utf-8")
|
||||
(root_dir / "moviepilot").write_text("#!/usr/bin/env bash\n", encoding="utf-8")
|
||||
(helper_dir / "sites.py").write_text("generated\n", encoding="utf-8")
|
||||
(helper_dir / "user.sites.v3.bin").write_bytes(b"binary")
|
||||
(resource_dir / "sites.py").write_text("generated\n", encoding="utf-8")
|
||||
(resource_dir / "user.sites.v3.bin").write_bytes(b"binary")
|
||||
(temp_config_dir / "moviepilot.runtime.json").write_text("{}", encoding="utf-8")
|
||||
(temp_config_dir / "moviepilot.frontend.runtime.json").write_text(
|
||||
"{}", encoding="utf-8"
|
||||
@@ -54,7 +54,7 @@ class LocalSetupUninstallTests(unittest.TestCase):
|
||||
stack = ExitStack()
|
||||
self.addCleanup(stack.close)
|
||||
stack.enter_context(patch.object(module, "ROOT", root_dir))
|
||||
stack.enter_context(patch.object(module, "HELPER_DIR", helper_dir))
|
||||
stack.enter_context(patch.object(module, "SITE_RESOURCE_DIR", resource_dir))
|
||||
stack.enter_context(patch.object(module, "RUNTIME_DIR", runtime_dir))
|
||||
stack.enter_context(patch.object(module, "PUBLIC_DIR", public_dir))
|
||||
stack.enter_context(patch.object(module, "INSTALL_ENV_FILE", install_env_file))
|
||||
@@ -109,8 +109,12 @@ class LocalSetupUninstallTests(unittest.TestCase):
|
||||
self.assertFalse(venv_dir.exists())
|
||||
self.assertFalse((root_dir / ".runtime").exists())
|
||||
self.assertFalse((root_dir / "public").exists())
|
||||
self.assertFalse((root_dir / "app" / "helper" / "sites.py").exists())
|
||||
self.assertFalse((root_dir / "app" / "helper" / "user.sites.v3.bin").exists())
|
||||
self.assertFalse(
|
||||
(root_dir / "app" / "infrastructure" / "sites.py").exists()
|
||||
)
|
||||
self.assertFalse(
|
||||
(root_dir / "app" / "infrastructure" / "user.sites.v3.bin").exists()
|
||||
)
|
||||
self.assertFalse(cli_link.exists())
|
||||
|
||||
def test_uninstall_deletes_external_config_when_requested(self):
|
||||
|
||||
@@ -7,8 +7,8 @@ from types import SimpleNamespace
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.factory import create_app, localized_http_exception_handler
|
||||
from app.helper.locale import LocaleHelper
|
||||
from app.helper.progress import ProgressHelper
|
||||
from app.platform.localization import LocaleHelper
|
||||
from app.platform.progress import ProgressHelper
|
||||
from app.schemas.dashboard import ScheduleInfo, ScheduleProgress
|
||||
from app.schemas.response import Response
|
||||
from version import APP_VERSION
|
||||
|
||||
@@ -2,7 +2,7 @@ import threading
|
||||
import time
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.log import LogEntry, NonBlockingFileHandler, log_settings
|
||||
from app.platform.log import LogEntry, NonBlockingFileHandler, log_settings
|
||||
|
||||
|
||||
def test_non_blocking_file_handler_shutdown_wakes_writer_and_closes_handlers(tmp_path):
|
||||
@@ -97,7 +97,7 @@ def test_non_blocking_file_handler_creates_one_handler_for_concurrent_first_writ
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
monkeypatch.setattr("app.log.RotatingFileHandler", ProbeHandler)
|
||||
monkeypatch.setattr("app.platform.log.RotatingFileHandler", ProbeHandler)
|
||||
file_path = tmp_path / "concurrent.log"
|
||||
|
||||
def get_handler(started=None):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from app.core.context import MusicInfo
|
||||
from app.domain.context import MusicInfo
|
||||
from app.modules.lrclib import LrclibModule
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parents[1]
|
||||
MAIN_PATH = PROJECT_ROOT / "app" / "main.py"
|
||||
|
||||
|
||||
def test_main_script_does_not_shadow_stdlib_platform(tmp_path):
|
||||
"""PyCharm 脚本启动路径不得让 app/platform 遮蔽标准库 platform。"""
|
||||
probe = "\n".join(
|
||||
(
|
||||
"import runpy",
|
||||
"import sys",
|
||||
f"sys.path.insert(0, {str(MAIN_PATH.parent)!r})",
|
||||
"sys.modules.pop('platform', None)",
|
||||
f"runpy.run_path({str(MAIN_PATH)!r}, run_name='pycharm_main_probe')",
|
||||
"import platform",
|
||||
"assert hasattr(platform, 'python_implementation')",
|
||||
"assert '/app/platform/' not in str(getattr(platform, '__file__', ''))",
|
||||
)
|
||||
)
|
||||
env = {
|
||||
**os.environ,
|
||||
"CONFIG_DIR": str(tmp_path / "config"),
|
||||
"MOVIEPILOT_AUTO_UPDATE": "off",
|
||||
}
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", probe],
|
||||
cwd=PROJECT_ROOT,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
@@ -5,9 +5,9 @@ from app.api.endpoints.transfer import (
|
||||
query_manual_transfer_history,
|
||||
)
|
||||
from app.chain.transfer import TransferChain
|
||||
from app.core.config import settings
|
||||
from app.platform.config import settings
|
||||
from app.db.transferhistory_oper import TransferHistoryOper
|
||||
from app.helper.transferhistory import (
|
||||
from app.services.history import (
|
||||
clear_transfer_failures,
|
||||
failed_retry_count,
|
||||
max_failed_retries,
|
||||
|
||||
@@ -11,8 +11,8 @@ from app.agent.tools.factory import MoviePilotToolFactory
|
||||
from app.agent.tools.manager import MoviePilotToolsManager
|
||||
from app.agent.tools.catalog import ToolCatalogSnapshot
|
||||
from app.api.endpoints import mcp
|
||||
from app.core.plugin import PluginManager
|
||||
from app.utils.singleton import Singleton
|
||||
from app.extensions.plugin_manager import PluginManager
|
||||
from app.foundation.singleton import Singleton
|
||||
|
||||
|
||||
class DemoPluginTool(MoviePilotTool):
|
||||
|
||||
@@ -4,10 +4,10 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
from app.chain.message import MediaInteractionChain, MessageChain
|
||||
from app.core.event import EventManager
|
||||
from app.core.context import Context, MediaInfo, TorrentInfo
|
||||
from app.core.meta import MetaBase
|
||||
from app.helper.interaction import media_interaction_manager, plugin_input_interaction_manager
|
||||
from app.platform.events import EventManager
|
||||
from app.domain.context import Context, MediaInfo, TorrentInfo
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.messaging.interaction import media_interaction_manager, plugin_input_interaction_manager
|
||||
from app.schemas import CommingMessage, TransferDirectoryConf
|
||||
from app.schemas.types import EventType, MediaSource, MediaType, MessageChannel
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ from unittest.mock import AsyncMock, Mock, patch
|
||||
import pytest
|
||||
|
||||
from app.api.endpoints.media import recognize
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.meta import MetaMusic
|
||||
from app.domain.context import MediaInfo
|
||||
from app.domain.meta.metamusic import MetaMusic
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import asyncio
|
||||
from unittest import TestCase
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.meta import MetaBase
|
||||
from app.domain.context import MediaInfo
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.modules.douban import DoubanModule
|
||||
from app.modules.themoviedb import TheMovieDbModule
|
||||
from app.modules.themoviedb.scraper import TmdbScraper
|
||||
|
||||
@@ -8,10 +8,11 @@ from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.chain.media import MediaChain
|
||||
from app.core.context import MediaInfo, MusicInfo
|
||||
from app.core.meta import MetaBase, MetaMusic
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.helper.server import MoviePilotServerHelper
|
||||
from app.domain.context import MediaInfo, MusicInfo
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.domain.meta.metamusic import MetaMusic
|
||||
from app.domain.metainfo import MetaInfo
|
||||
from app.integrations.server import MoviePilotServerHelper
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.meta import MetaBase
|
||||
from app.helper.server import MoviePilotServerHelper
|
||||
from app.domain.context import MediaInfo
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.integrations.server import MoviePilotServerHelper
|
||||
from app.schemas.types import MediaSource, MediaType, SystemConfigKey
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from fastapi.testclient import TestClient
|
||||
from app import schemas
|
||||
from app.api.endpoints import mediaserver as mediaserver_endpoint
|
||||
from app.api.response import ResponseAPIRouter
|
||||
from app.core.context import MediaInfo as CoreMediaInfo
|
||||
from app.domain.context import MediaInfo as CoreMediaInfo
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
|
||||
|
||||
|
||||
@@ -4,9 +4,10 @@ import pytest
|
||||
|
||||
from app.api.endpoints import media as media_endpoint
|
||||
from app.api.endpoints.media import recognize_file, scrape
|
||||
from app.core.context import Context, MediaInfo
|
||||
from app.core.meta import MetaBase, MetaMusic
|
||||
from app.core.context import MusicInfo
|
||||
from app.domain.context import Context, MediaInfo
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.domain.meta.metamusic import MetaMusic
|
||||
from app.domain.context import MusicInfo
|
||||
from app.schemas import FileItem, MediaType
|
||||
from app.schemas.types import MediaSource
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from fastapi import FastAPI
|
||||
from app.api.endpoints import media as media_endpoints
|
||||
from app.api.endpoints.media import search
|
||||
from app.chain import ChainBase
|
||||
from app.core.security import verify_token
|
||||
from app.security.access import verify_token
|
||||
from app.modules.douban import DoubanModule
|
||||
from app.modules.themoviedb import TheMovieDbModule
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.meta import MetaBase
|
||||
from app.domain.context import MediaInfo
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
from app.utils.media import (
|
||||
from app.domain.media import (
|
||||
build_media_key,
|
||||
parse_media_source_selection,
|
||||
resolve_media_identity,
|
||||
|
||||
@@ -9,15 +9,15 @@ from app.testing import stub_modules
|
||||
_systemconfig_stub = MagicMock()
|
||||
_systemconfig_stub.SystemConfigOper.return_value.get.return_value = None
|
||||
with stub_modules({
|
||||
'app.helper.sites': MagicMock(),
|
||||
'app.infrastructure.sites': MagicMock(),
|
||||
'app.db.systemconfig_oper': _systemconfig_stub,
|
||||
}):
|
||||
from app import schemas
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.scraping import ScrapingChain, ScrapingConfig, ScrapingOption
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.event import Event
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.domain.context import MediaInfo
|
||||
from app.platform.events import Event
|
||||
from app.domain.metainfo import MetaInfo
|
||||
from app.schemas.types import EventType, MediaType, ScrapingTarget, ScrapingMetadata, ScrapingPolicy
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from app.helper.service import ServiceConfigHelper
|
||||
from app.extensions.service_registry import ServiceConfigHelper
|
||||
from app.schemas.system import MediaServerConf
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
@@ -16,7 +16,7 @@ def test_mediaserver_conf_tolerates_blank_sync_interval():
|
||||
def test_get_configs_skips_invalid_entries(monkeypatch):
|
||||
"""单条配置校验失败时应跳过该条,不影响其它服务配置的加载。"""
|
||||
monkeypatch.setattr(
|
||||
"app.helper.service.SystemConfigOper.get",
|
||||
"app.extensions.service_registry.SystemConfigOper.get",
|
||||
lambda self, key: [
|
||||
{"name": "good", "type": "emby", "enabled": True},
|
||||
"bad-format",
|
||||
|
||||
@@ -3,7 +3,7 @@ from unittest.mock import Mock
|
||||
|
||||
from app.chain.mediaserver import MediaServerChain
|
||||
from app.schemas import MediaServerLibrary, MediaServerPlayItem
|
||||
from app.utils.security import SecurityUtils
|
||||
from app.security.url import SecurityUtils
|
||||
|
||||
|
||||
class MediaServerImageSigningTest(unittest.TestCase):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from app.core.context import MediaInfo
|
||||
from app.helper.message import TemplateContextBuilder
|
||||
from app.domain.context import MediaInfo
|
||||
from app.messaging.message import TemplateContextBuilder
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
|
||||
|
||||
|
||||
@@ -4,13 +4,13 @@ from unittest.mock import Mock
|
||||
|
||||
from app.api.endpoints.message import clear_notification_message, get_notification_message
|
||||
from app.chain import ChainBase
|
||||
from app.core.context import Context, MediaInfo, TorrentInfo
|
||||
from app.core.meta import MetaBase
|
||||
from app.domain.context import Context, MediaInfo, TorrentInfo
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.db import AsyncSessionFactory, SessionFactory
|
||||
from app.db.message_oper import MessageOper
|
||||
from app.db.models.message import Message
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.helper.message import MessageHelper
|
||||
from app.messaging.message import MessageHelper
|
||||
from app.schemas import Notification, NotificationClearScope
|
||||
from app.schemas.types import MediaType, NotificationType, SystemConfigKey
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@ class TestMessageProcessingStatus(unittest.TestCase):
|
||||
"metadata": {"kind": "typing"},
|
||||
}
|
||||
|
||||
with patch("app.agent.AgentChain") as chain_cls:
|
||||
with patch("app.agent.orchestrator.AgentChain") as chain_cls:
|
||||
_finish_processing_status(status, user_id="fallback")
|
||||
|
||||
chain_cls.return_value.finish_message_processing_status.assert_called_once_with(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import time
|
||||
|
||||
from app.helper.message import MessageQueueManager, TemplateHelper, stop_message
|
||||
from app.utils.singleton import SingletonClass
|
||||
from app.messaging.message import MessageQueueManager, TemplateHelper, stop_message
|
||||
from app.foundation.singleton import SingletonClass
|
||||
|
||||
|
||||
def test_message_queue_stop_wakes_idle_monitor(monkeypatch):
|
||||
|
||||
+26
-25
@@ -5,10 +5,11 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.metainfo import MetaInfo, MetaInfoPath, find_metainfo
|
||||
from app.core.meta import MetaBase, MetaMusic
|
||||
from app.core.meta.metaanime import MetaAnime
|
||||
from app.helper.torrent import TorrentHelper
|
||||
from app.domain.metainfo import MetaInfo, MetaInfoPath, find_metainfo
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.domain.meta.metamusic import MetaMusic
|
||||
from app.domain.meta.metaanime import MetaAnime
|
||||
from app.services.torrent import TorrentHelper
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
from tests.cases.meta import meta_cases
|
||||
|
||||
@@ -161,7 +162,7 @@ def test_torrent_title_match_ignores_question_mark_variants():
|
||||
|
||||
def test_python_metainfo_fallback_preserves_xxx_movie_title():
|
||||
"""Python 兜底解析不应删除合法 xXx 片名。"""
|
||||
with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None):
|
||||
with patch("app.infrastructure.rust.parse_metainfo", return_value=None):
|
||||
meta = MetaInfo("xXx 2002 1080p AMZN WEB-DL H.264 DDP 5.1-FROGWeb")
|
||||
|
||||
assert meta.en_name == "Xxx"
|
||||
@@ -173,7 +174,7 @@ def test_python_metainfo_fallback_preserves_xxx_movie_title():
|
||||
|
||||
def test_python_metainfo_fallback_recognizes_eac3_audio_codec():
|
||||
"""Python 兜底解析应识别 EAC3 及其声道信息。"""
|
||||
with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None):
|
||||
with patch("app.infrastructure.rust.parse_metainfo", return_value=None):
|
||||
meta = MetaInfo("Test.Movie.2026.1080p.BluRay.x264.EAC3.5.1-GROUP")
|
||||
|
||||
assert meta.resource_pix == "1080p"
|
||||
@@ -215,7 +216,7 @@ def test_metainfo_preserves_all_resource_types(title, expected):
|
||||
@pytest.mark.parametrize(("title", "expected"), RESOURCE_TYPE_CASES)
|
||||
def test_python_metainfo_preserves_all_resource_types(title, expected):
|
||||
"""Python 兜底解析应按顺序保留并去重所有受支持的资源类型。"""
|
||||
with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None):
|
||||
with patch("app.infrastructure.rust.parse_metainfo", return_value=None):
|
||||
meta = MetaInfo(title)
|
||||
|
||||
assert meta.resource_type == expected
|
||||
@@ -276,7 +277,7 @@ def test_metainfo_music_round_trip_preserves_fields():
|
||||
|
||||
def test_python_subtitle_episode_range_fin_with_chinese_season():
|
||||
"""Python 兜底解析应识别副标题中 [01-26Fin] 格式的集数范围(#6103)。"""
|
||||
with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None):
|
||||
with patch("app.infrastructure.rust.parse_metainfo", return_value=None):
|
||||
meta = MetaInfo(
|
||||
title="JoJos Bizarre Adventure S01 2012 1080i BluRay x264 FLAC 2.0-AnimeF@ADE",
|
||||
subtitle="JOJO的奇妙冒险 第一季 / JoJo's Bizarre Adventure [01-26Fin] [简繁字幕]",
|
||||
@@ -305,7 +306,7 @@ def test_subtitle_episode_range_fin_with_default_parser():
|
||||
|
||||
def test_python_subtitle_episode_range_fin_without_chinese_marker():
|
||||
"""副标题无中文季集标记时也应识别 [01-38Fin] 集数范围。"""
|
||||
with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None):
|
||||
with patch("app.infrastructure.rust.parse_metainfo", return_value=None):
|
||||
meta = MetaInfo(
|
||||
title="Some Show S01 2022 1080p WEB-DL H264-GRP",
|
||||
subtitle="Some Show [01-38Fin]",
|
||||
@@ -318,7 +319,7 @@ def test_python_subtitle_episode_range_fin_without_chinese_marker():
|
||||
|
||||
def test_python_subtitle_episode_range_end_variant():
|
||||
"""END/完结 等完结标记变体同样应识别为集数范围。"""
|
||||
with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None):
|
||||
with patch("app.infrastructure.rust.parse_metainfo", return_value=None):
|
||||
meta_end = MetaInfo(
|
||||
title="Some Show S01 2022 1080p WEB-DL H264-GRP",
|
||||
subtitle="Some Show 01-24 END",
|
||||
@@ -336,7 +337,7 @@ def test_python_subtitle_episode_range_end_variant():
|
||||
|
||||
def test_python_subtitle_year_range_not_treated_as_episodes():
|
||||
"""年份范围(如 2019-2020)不得误识别为集数。"""
|
||||
with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None):
|
||||
with patch("app.infrastructure.rust.parse_metainfo", return_value=None):
|
||||
meta = MetaInfo(
|
||||
title="Some Collection 2020 1080p WEB-DL H264-GRP",
|
||||
subtitle="A Collection [2019-2020Fin]",
|
||||
@@ -349,7 +350,7 @@ def test_python_subtitle_year_range_not_treated_as_episodes():
|
||||
def test_python_subtitle_episode_range_fin_rejects_numeric_suffix():
|
||||
"""Python 兜底解析不得把带数字后缀的完结范围截断识别为集数。"""
|
||||
for subtitle in ("Some Show [01-26Fin]2", "Some Show 01-26Fin 2"):
|
||||
with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None):
|
||||
with patch("app.infrastructure.rust.parse_metainfo", return_value=None):
|
||||
meta = MetaInfo(
|
||||
title="Some Show S01 2022 1080p WEB-DL H264-GRP",
|
||||
subtitle=subtitle,
|
||||
@@ -389,7 +390,7 @@ def test_custom_words_episode_offset_supports_multiplication_expression():
|
||||
r"Ha.Ha.Ha.Ha.Ha.2026.S06E([0-1][0-9]).Part1 => 哈哈哈哈哈 (2020){[tmdbid=112732;type=tv]} S06E\1.Part1 && S06 <> .Part1 >> 2*EP-1"
|
||||
]
|
||||
|
||||
with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None):
|
||||
with patch("app.infrastructure.rust.parse_metainfo", return_value=None):
|
||||
meta = MetaInfo(
|
||||
title="Ha.Ha.Ha.Ha.Ha.2026.S06E03.Part1",
|
||||
custom_words=custom_words,
|
||||
@@ -407,7 +408,7 @@ def test_custom_words_episode_offset_supports_repeated_ep_expression():
|
||||
"""测试集数偏移表达式支持重复使用 EP 占位符。"""
|
||||
custom_words = ["旧名 => 新名 && 第 <> 集 >> EP+EP-1"]
|
||||
|
||||
with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None):
|
||||
with patch("app.infrastructure.rust.parse_metainfo", return_value=None):
|
||||
meta = MetaInfo(title="旧名 第03集", custom_words=custom_words)
|
||||
|
||||
assert meta.name == "新名"
|
||||
@@ -419,7 +420,7 @@ def test_custom_words_episode_offset_rejects_implicit_ep_expression():
|
||||
"""测试集数偏移表达式不把 2EP 当作隐式乘法或字符串拼接。"""
|
||||
custom_words = ["旧名 => 新名 && 第 <> 集 >> 2EP"]
|
||||
|
||||
with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None):
|
||||
with patch("app.infrastructure.rust.parse_metainfo", return_value=None):
|
||||
meta = MetaInfo(title="旧名 第03集", custom_words=custom_words)
|
||||
|
||||
assert meta.name == "新名"
|
||||
@@ -448,7 +449,7 @@ def test_custom_words_support_special_season_zero_parameter():
|
||||
"Test Show => 测试剧 {[tmdbid=12345;type=tv;s=0]}"
|
||||
]
|
||||
|
||||
with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None):
|
||||
with patch("app.infrastructure.rust.parse_metainfo", return_value=None):
|
||||
meta = MetaInfo(title="Test Show 01", custom_words=custom_words)
|
||||
|
||||
assert meta.media_source == MediaSource.TMDB
|
||||
@@ -468,7 +469,7 @@ def test_find_metainfo_supports_episode_group_parameter():
|
||||
def test_find_metainfo_does_not_support_episode_group_alias():
|
||||
"""测试 e_group 不会被当作剧集组参数识别。"""
|
||||
group_id = "5ad0ec240e0a26303f00d84d"
|
||||
with patch("app.core.metainfo.rust_accel.find_metainfo", return_value=None):
|
||||
with patch("app.infrastructure.rust.find_metainfo", return_value=None):
|
||||
_, metainfo = find_metainfo(f"物语系列 {{[tmdbid=46195;type=tv;e_group={group_id};s=1]}}")
|
||||
assert metainfo["episode_group"] is None
|
||||
|
||||
@@ -482,7 +483,7 @@ def test_video_bit_extracted_for_video_title():
|
||||
|
||||
def test_special_season_zero_enables_whole_season_resource_parsing():
|
||||
"""只有 S00、没有集号的整季标题仍应识别后续编码信息。"""
|
||||
with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None):
|
||||
with patch("app.infrastructure.rust.parse_metainfo", return_value=None):
|
||||
meta = MetaInfo(title="Demo Show S00 X265 AAC")
|
||||
|
||||
assert meta.begin_season == 0
|
||||
@@ -497,7 +498,7 @@ def test_anime_parser_preserves_numeric_special_season_zero():
|
||||
"anime_season": 0,
|
||||
"episode_number": "1",
|
||||
}
|
||||
with patch("app.core.meta.metaanime.anitopy.parse", return_value=parsed):
|
||||
with patch("app.domain.meta.metaanime.anitopy.parse", return_value=parsed):
|
||||
meta = MetaAnime(title="Demo Anime S00E01")
|
||||
|
||||
assert meta.begin_season == 0
|
||||
@@ -505,7 +506,7 @@ def test_anime_parser_preserves_numeric_special_season_zero():
|
||||
assert meta.type == MediaType.TV
|
||||
|
||||
parsed["anime_season"] = [0, "1"]
|
||||
with patch("app.core.meta.metaanime.anitopy.parse", return_value=parsed):
|
||||
with patch("app.domain.meta.metaanime.anitopy.parse", return_value=parsed):
|
||||
ranged_meta = MetaAnime(title="Demo Anime S00-S01")
|
||||
|
||||
assert ranged_meta.begin_season == 0
|
||||
@@ -525,9 +526,9 @@ def test_anime_parser_ignores_empty_and_invalid_season_values():
|
||||
"episode_number": "1",
|
||||
}
|
||||
|
||||
with patch("app.core.meta.metaanime.anitopy.parse", return_value=empty):
|
||||
with patch("app.domain.meta.metaanime.anitopy.parse", return_value=empty):
|
||||
empty_meta = MetaAnime(title="Demo Anime E01")
|
||||
with patch("app.core.meta.metaanime.anitopy.parse", return_value=invalid_list):
|
||||
with patch("app.domain.meta.metaanime.anitopy.parse", return_value=invalid_list):
|
||||
invalid_meta = MetaAnime(title="Demo Anime E01")
|
||||
|
||||
assert empty_meta.begin_season is None
|
||||
@@ -536,7 +537,7 @@ def test_anime_parser_ignores_empty_and_invalid_season_values():
|
||||
|
||||
def test_hdr_vivid_effect_extracted_for_video_title():
|
||||
"""测试合并写法 HDRVivid 可识别为资源效果。"""
|
||||
with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None):
|
||||
with patch("app.infrastructure.rust.parse_metainfo", return_value=None):
|
||||
meta = MetaInfo(
|
||||
title="Never-Ending Summer 2026 S01E18-S01E19 2160p WEB-DL 50Fps "
|
||||
"HDRVivid H265 10bit AAC-XXWEB"
|
||||
@@ -559,7 +560,7 @@ def test_video_bit_extracted_for_anime_title():
|
||||
|
||||
def test_streaming_platform_word_kept_in_movie_title():
|
||||
"""测试正式片名中的流媒体平台词不会被预置清理规则移除。"""
|
||||
with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None):
|
||||
with patch("app.infrastructure.rust.parse_metainfo", return_value=None):
|
||||
meta = MetaInfo(title="Amazon Forever 2004 1080p WEB-DL")
|
||||
assert meta.name == "Amazon Forever"
|
||||
assert meta.year == "2004"
|
||||
@@ -589,7 +590,7 @@ def test_custom_identifier_uses_source_specific_id_and_returns_unified_identity(
|
||||
def test_generic_media_identity_is_not_custom_identifier_syntax():
|
||||
"""通用身份字段不得因 Rust 扩展版本差异被自定义识别词解析器接收。"""
|
||||
with patch(
|
||||
"app.core.metainfo.rust_accel.find_metainfo",
|
||||
"app.infrastructure.rust.find_metainfo",
|
||||
side_effect=AssertionError("通用身份标签必须绕过 Rust 解析器"),
|
||||
):
|
||||
_, metainfo = find_metainfo(
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.meta import (
|
||||
from app.domain.meta.metamusic import (
|
||||
MetaMusic,
|
||||
MusicNameContext,
|
||||
MusicNameParseResult,
|
||||
@@ -11,7 +11,7 @@ from app.core.meta import (
|
||||
MusicNamePattern,
|
||||
MusicNameRegistry,
|
||||
)
|
||||
from app.core.metainfo import MetaInfo, MetaInfoPath
|
||||
from app.domain.metainfo import MetaInfo, MetaInfoPath
|
||||
|
||||
|
||||
def parse_title(title: str) -> MetaMusic:
|
||||
@@ -48,7 +48,7 @@ def test_music_name_registry_supports_dynamic_pattern_and_parser(monkeypatch):
|
||||
)
|
||||
try:
|
||||
monkeypatch.setattr(
|
||||
"app.core.meta.metamusic.rust_accel.parse_metamusic",
|
||||
"app.infrastructure.rust.parse_metamusic",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
||||
AssertionError("自定义注册表不应调用 Rust")
|
||||
),
|
||||
@@ -100,7 +100,7 @@ def test_music_name_registry_same_name_replacement_falls_back_to_python(monkeypa
|
||||
MusicNameRegistry.register_parser(replacement, replace=True)
|
||||
try:
|
||||
monkeypatch.setattr(
|
||||
"app.core.meta.metamusic.rust_accel.parse_metamusic",
|
||||
"app.infrastructure.rust.parse_metamusic",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
||||
AssertionError("同名替换解析器时不应调用 Rust")
|
||||
),
|
||||
@@ -139,7 +139,7 @@ def test_parse_query_uses_rust_and_maps_music_fields(monkeypatch):
|
||||
return parsed
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.core.meta.metamusic.rust_accel.parse_metamusic",
|
||||
"app.infrastructure.rust.parse_metamusic",
|
||||
parse_metamusic,
|
||||
)
|
||||
|
||||
@@ -155,7 +155,7 @@ def test_parse_query_uses_rust_and_maps_music_fields(monkeypatch):
|
||||
def test_apply_title_preserves_existing_fields_from_rust(monkeypatch):
|
||||
"""Rust 解析只应补充空字段,不覆盖标签或文件后缀证据。"""
|
||||
monkeypatch.setattr(
|
||||
"app.core.meta.metamusic.rust_accel.parse_metamusic",
|
||||
"app.infrastructure.rust.parse_metamusic",
|
||||
lambda *_args, **_kwargs: {
|
||||
"title": "Rust 曲名",
|
||||
"artists": ["Rust 歌手"],
|
||||
@@ -202,7 +202,7 @@ def test_apply_title_preserves_existing_fields_from_rust(monkeypatch):
|
||||
def test_apply_title_falls_back_when_rust_returns_none(monkeypatch):
|
||||
"""Rust wrapper 不可用时应完整执行现有 Python 命名解析。"""
|
||||
monkeypatch.setattr(
|
||||
"app.core.meta.metamusic.rust_accel.parse_metamusic",
|
||||
"app.infrastructure.rust.parse_metamusic",
|
||||
lambda *_args, **_kwargs: None,
|
||||
)
|
||||
|
||||
@@ -231,7 +231,7 @@ def test_metainfo_audio_suffix_remains_authoritative_with_rust(monkeypatch):
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.core.meta.metamusic.rust_accel.parse_metamusic",
|
||||
"app.infrastructure.rust.parse_metamusic",
|
||||
parse_metamusic,
|
||||
)
|
||||
filename = "S H E - S H E十七音乐会 2018 WEB-DL 1080P AVC AAC-FHDMv.flac"
|
||||
@@ -263,7 +263,7 @@ def test_metainfo_path_uses_rust_once_and_keeps_python_directory_context(
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.core.meta.metamusic.rust_accel.parse_metamusic",
|
||||
"app.infrastructure.rust.parse_metamusic",
|
||||
parse_metamusic,
|
||||
)
|
||||
path = MetaInfoPath(
|
||||
|
||||
@@ -5,8 +5,8 @@ import pytest
|
||||
from webauthn.helpers.exceptions import InvalidRegistrationResponse
|
||||
|
||||
from app.api.endpoints import mfa as mfa_endpoint
|
||||
from app.helper import passkey as passkey_helper
|
||||
from app.helper.passkey import (
|
||||
from app.security import passkey as passkey_helper
|
||||
from app.security.passkey import (
|
||||
PassKeyHelper,
|
||||
PassKeyRegistrationOriginMismatchError,
|
||||
PassKeyRegistrationVerificationError,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user