test: add pytest scaffold and agenttokens plugin test (#1033)

This commit is contained in:
InfinityPacer
2026-06-03 10:51:53 +08:00
committed by GitHub
parent 60451c9b7f
commit 75925415a3
9 changed files with 288 additions and 0 deletions

11
pytest.ini Normal file
View File

@@ -0,0 +1,11 @@
[pytest]
# 仅在仓库根 tests/ 下发现用例插件目录plugins/、plugins.v2/)不再承载测试
testpaths = tests
python_files = test_*.py
# unittest.TestCase 子类无论命名都会被收集;此项额外兼容将来可能出现的纯函数式测试类
python_classes = *Test Test*
python_functions = test_*
# v1/v2 必须分会话运行同名插件包冲突marker 由 conftest 按目录自动打,便于 -m 选择
markers =
v1: v1 插件plugins/)单测,需与 v2 分独立会话运行
v2: v2 插件plugins.v2/)单测,需与 v1 分独立会话运行

44
tests/README.md Normal file
View File

@@ -0,0 +1,44 @@
# 插件仓单测
测试统一放在仓库根 `tests/` 下,**不放在插件目录内**——插件的本地同步与市场下发按
整目录拷贝(`shutil.copytree`),插件目录内的测试会被一并下发到运行时副本。
## 目录结构
```
tests/
├─ _bootstrap.py 共享引导:隔离 CONFIG_DIR + 注入后端/插件目录到 sys.path
├─ conftest.py pytest 引导:收集前隔离 CONFIG_DIR按目录自动打 v1/v2 marker
├─ v2/ v2 插件plugins.v2/)单测
└─ v1/ v1 插件plugins/)单测(当前预留骨架)
```
## 运行
需要 MoviePilot 后端置于插件仓**同级目录**(或设环境变量 `MOVIEPILOT_BACKEND_PATH`
并使用带后端依赖的解释器(如 `<workspace>/.venv/bin/python`)。
```bash
# 全量推荐入口v1/v2 各自独立会话依次跑,命令行参数透传给 pytest
<workspace>/.venv/bin/python tests/run.py
# 也可按代单独跑v1/v2 必须分会话,勿混跑)
<workspace>/.venv/bin/python -m pytest tests/v2
<workspace>/.venv/bin/python -m pytest tests/v1
```
`tests/run.py` 把 v1/v2 放在独立子进程依次运行、无用例的代自动跳过——两代存在同名
插件包(如 `brushflowlowfreq``torrentclassifier`),同一解释器进程无法同时加载、混跑
会相互覆盖。后端依赖(`app.*`)由 `_bootstrap.py` 注入 `sys.path`,并隔离临时 `CONFIG_DIR`
且建表;主程序 `app/testing` 的共享 harness`stub_modules` 等)在 bootstrap 后可直接复用。
## 提 PR / push 前
先本地 `python tests/run.py` 跑**全量并确认通过**,再 push / 提 PR。
## 新增用例
1. 放到对应代际目录(`tests/v2/``tests/v1/`),文件名 `test_*.py`
2. 顶部调用 `prepare_v2_backend()` / `prepare_v1_backend()`(见 `_bootstrap.py`
必须早于首个 `import app.*` 或插件包导入;
3. 优先用 `object.__new__` 绕过插件 `__init__`,只测纯逻辑方法,避免依赖完整运行时。

7
tests/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
"""插件仓单测包。
测试统一置于仓库根 ``tests/`` 下并按 v1/v2 分治,刻意不放在插件目录内:
插件的本地同步与市场下发按整目录拷贝(``shutil.copytree``),任何放在
``plugins/<id>/`` 或 ``plugins.v2/<id>/`` 内的测试都会被一并下发到运行时副本,
既污染线上插件目录,也可能在缺少后端依赖时影响加载。
"""

118
tests/_bootstrap.py Normal file
View File

@@ -0,0 +1,118 @@
"""插件仓单测共享引导。
为复用 MoviePilot 后端逻辑的插件单测提供统一的运行前准备。所有函数都必须在
首次 ``import app.*`` 或导入任一插件包之前调用,否则隔离与路径注入不生效。
职责:
1. 把 ``CONFIG_DIR`` 指向进程私有临时目录,隔离主程序真实数据库与配置;
2. 定位 MoviePilot 后端并加入 ``sys.path``,使 ``import app.*`` 可用;
3. 按 v1 / v2 分别加入插件源码目录到 ``sys.path``。
关键约束v1``plugins/``)与 v2``plugins.v2/``)存在同名插件包,
同一解释器进程无法同时加载两代同名包,必须在各自独立的 pytest 会话中运行,
因此 v1 / v2 的引导函数分开提供、互斥使用。
"""
from __future__ import annotations
import atexit
import os
import shutil
import sys
import tempfile
from pathlib import Path
from typing import Optional
# ``tests/`` 的父级即插件仓根;其同级 ``MoviePilot`` 为后端默认位置(工作区多仓同级布局)
_TESTS_DIR = Path(__file__).resolve().parent
_PLUGINS_REPO = _TESTS_DIR.parent
_WORKSPACE_ROOT = _PLUGINS_REPO.parent
# 记录本进程隔离出的临时 CONFIG_DIR兼作幂等标记
_isolated_config_dir: Optional[str] = None
def isolate_config_dir() -> str:
"""把 ``CONFIG_DIR`` 指向进程私有临时目录,隔离主程序真实库与配置。
``import app.chain.*`` 会按 ``settings.CONFIG_PATH`` 直接连 ``user.db``;在
本地非容器布局下默认落到 ``MoviePilot/config/user.db``(线上真实库),因此必须
在首个 ``import app.*`` 之前改写。幂等:重复调用返回同一目录。
若调用方已显式设置 ``CONFIG_DIR``(如 CI 指定的隔离目录),则尊重之、不覆盖。
:return: 实际生效的 CONFIG_DIR 绝对路径
"""
global _isolated_config_dir
if _isolated_config_dir is not None:
return _isolated_config_dir
existing = os.environ.get("CONFIG_DIR")
if existing:
_isolated_config_dir = existing
return existing
tmp = tempfile.mkdtemp(prefix="mp-plugin-test-config-")
os.environ["CONFIG_DIR"] = tmp
_isolated_config_dir = tmp
# 进程退出时清理临时库与目录,避免 /tmp 堆积
atexit.register(shutil.rmtree, tmp, ignore_errors=True)
return tmp
def _resolve_backend_path() -> Path:
"""定位 MoviePilot 后端根目录。
优先取环境变量 ``MOVIEPILOT_BACKEND_PATH``(便于 CI 或非同级布局覆盖);
否则按工作区同级布局推导 ``<workspace>/MoviePilot``。校验 ``app/`` 存在,
避免把错误路径塞进 ``sys.path`` 后产生误导性的 ``ImportError``。
"""
candidates = []
env = os.environ.get("MOVIEPILOT_BACKEND_PATH")
if env:
candidates.append(Path(env).expanduser())
candidates.append(_WORKSPACE_ROOT / "MoviePilot")
for path in candidates:
if (path / "app").is_dir():
return path
raise RuntimeError(
"未找到 MoviePilot 后端app/ 不存在)。请将后端置于插件仓同级目录,"
f"或设置环境变量 MOVIEPILOT_BACKEND_PATH。已尝试: {[str(c) for c in candidates]}"
)
def _prepend_sys_path(path: Path) -> None:
"""把目录前置到 ``sys.path``(去重),使其内的顶层包可被导入。"""
value = str(path)
if value not in sys.path:
sys.path.insert(0, value)
def prepare_backend() -> None:
"""隔离配置目录、加入后端到 ``sys.path`` 并建表(不加载任何插件源码)。
仅需 ``import app.*`` 而不触碰具体插件包的用例可直接调用本函数。
"""
isolate_config_dir()
_prepend_sys_path(_resolve_backend_path())
# 隔离出的临时库为空,插件读取 systemconfig 等表会报 no such table与主程序 conftest 一致建表。
# init_db 仅 import models + create_all无 alembic/网络、幂等、毫秒级。
from app.db.init import init_db
init_db()
def prepare_v2_backend() -> None:
"""v2 插件单测引导:后端 + ``plugins.v2/`` 源码目录。
调用后可 ``import app.*`` 与 ``import <v2 插件包名>``。与 v1 引导互斥,
勿在同一进程内混用。
"""
prepare_backend()
_prepend_sys_path(_PLUGINS_REPO / "plugins.v2")
def prepare_v1_backend() -> None:
"""v1 插件单测引导:后端 + ``plugins/`` 源码目录。
与 :func:`prepare_v2_backend` 互斥v1/v2 存在同名插件包,同一进程同时加载会
相互覆盖,请在独立 pytest 会话中分别运行 ``tests/v1`` 与 ``tests/v2``。
"""
prepare_backend()
_prepend_sys_path(_PLUGINS_REPO / "plugins")

31
tests/conftest.py Normal file
View File

@@ -0,0 +1,31 @@
"""pytest 全局引导。
在收集任何用例之前隔离 ``CONFIG_DIR``,确保后续 ``import app.*`` 不会连到主程序
真实库。``sys.path`` 的后端 / 插件目录注入交由各用例按 v1/v2 显式引导
(见 :mod:`tests._bootstrap`),不在收集阶段引入任一代插件包,以规避 v1/v2 同名包冲突。
"""
import sys
from pathlib import Path
import pytest
# 将仓库根置于 sys.path使共享引导 tests._bootstrap 可被导入(兼容 pytest 与直接运行)
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from tests._bootstrap import isolate_config_dir # noqa: E402
# conftest 早于测试模块收集执行,保证 CONFIG_DIR 在首个 import app.* 之前生效
isolate_config_dir()
def pytest_collection_modifyitems(config, items):
"""按所在目录自动为用例打 v1/v2 marker支持 ``pytest -m v2`` 选择运行。
避免每个用例手动标注;与按目录运行(``pytest tests/v2``)二选一皆可。
"""
for item in items:
path = str(item.fspath).replace("\\", "/")
if "/tests/v2/" in path:
item.add_marker(pytest.mark.v2)
elif "/tests/v1/" in path:
item.add_marker(pytest.mark.v1)

34
tests/run.py Normal file
View File

@@ -0,0 +1,34 @@
"""插件仓全量单测入口v1/v2 各自独立 pytest 会话运行,命令行参数透传给 pytest。
plugins/v1与 plugins.v2/v2存在同名插件包同一进程无法同时加载故各代在
独立子进程运行;任一代非零退出码即整体失败,无用例的代直接跳过。路径以 __file__
推导,从任意目录调用均可。
"""
import subprocess
import sys
from pathlib import Path
# 本文件位于 tests/ 下:其父为 tests 目录,再上一级为插件仓根
_TESTS_DIR = Path(__file__).resolve().parent
_REPO_ROOT = _TESTS_DIR.parent
def _run_generation(generation: str, extra_args: list) -> int:
"""在独立子进程运行某一代v1/v2的全部用例该代无用例则跳过、返回 0。"""
target = _TESTS_DIR / generation
if not list(target.rglob("test_*.py")):
return 0
return subprocess.call(
[sys.executable, "-m", "pytest", str(target), *extra_args],
cwd=str(_REPO_ROOT),
)
if __name__ == "__main__":
extra = sys.argv[1:]
exit_code = 0
# v1/v2 必须分会话;按代依次跑,保留首个非零退出码作为整体结果
for generation in ("v2", "v1"):
rc = _run_generation(generation, extra)
exit_code = exit_code or rc
sys.exit(exit_code)

5
tests/v1/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""v1 插件plugins/)单测包。
当前工作区仅维护 v2 插件,本目录预留骨架、暂不承载用例。
与 v2 分会话运行v1/v2 存在同名插件包,同一解释器进程无法同时加载。
"""

4
tests/v2/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
"""v2 插件plugins.v2/)单测包。
与 v1 分会话运行v1/v2 存在同名插件包,同一解释器进程无法同时加载。
"""

View File

@@ -0,0 +1,34 @@
"""AgentTokens 插件单测pytest 原生)。
覆盖侧栏入口受 show_sidebar_nav 配置控制的逻辑。依赖 MoviePilot 后端app.*)与
插件包:通过共享引导隔离 CONFIG_DIR 并把后端、plugins.v2 注入 sys.path再以
顶层包名导入插件。
"""
import sys
from pathlib import Path
from unittest.mock import patch
# 将仓库根置于 sys.path使共享引导可被导入兼容 pytest 与直接 python 运行)
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from tests._bootstrap import prepare_v2_backend # noqa: E402
# 隔离 CONFIG_DIR 并注入后端 + plugins.v2必须在 import app.* / 插件包之前完成
prepare_v2_backend()
from agenttokens import AgentTokens # noqa: E402
def test_sidebar_nav_respects_config():
"""侧栏入口应受 show_sidebar_nav 配置控制:关闭则不注册,开启且插件启用则注册。
init_plugin 内部会持久化配置,这里 patch 掉 update_config仅隔离验证侧栏逻辑。
"""
plugin = AgentTokens()
with patch.object(plugin, "update_config"):
plugin.init_plugin({"enabled": True, "show_sidebar_nav": False, "providers": []})
assert plugin.get_sidebar_nav() == []
plugin.init_plugin({"enabled": True, "show_sidebar_nav": True, "providers": []})
nav = plugin.get_sidebar_nav()
assert nav[0]["title"] == "Agent Tokens 管理"