mirror of
https://github.com/jxxghp/MoviePilot-Plugins.git
synced 2026-06-07 07:26:49 +00:00
test: add pytest scaffold and agenttokens plugin test (#1033)
This commit is contained in:
11
pytest.ini
Normal file
11
pytest.ini
Normal 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
44
tests/README.md
Normal 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
7
tests/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""插件仓单测包。
|
||||
|
||||
测试统一置于仓库根 ``tests/`` 下并按 v1/v2 分治,刻意不放在插件目录内:
|
||||
插件的本地同步与市场下发按整目录拷贝(``shutil.copytree``),任何放在
|
||||
``plugins/<id>/`` 或 ``plugins.v2/<id>/`` 内的测试都会被一并下发到运行时副本,
|
||||
既污染线上插件目录,也可能在缺少后端依赖时影响加载。
|
||||
"""
|
||||
118
tests/_bootstrap.py
Normal file
118
tests/_bootstrap.py
Normal 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
31
tests/conftest.py
Normal 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
34
tests/run.py
Normal 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
5
tests/v1/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""v1 插件(plugins/)单测包。
|
||||
|
||||
当前工作区仅维护 v2 插件,本目录预留骨架、暂不承载用例。
|
||||
与 v2 分会话运行:v1/v2 存在同名插件包,同一解释器进程无法同时加载。
|
||||
"""
|
||||
4
tests/v2/__init__.py
Normal file
4
tests/v2/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""v2 插件(plugins.v2/)单测包。
|
||||
|
||||
与 v1 分会话运行:v1/v2 存在同名插件包,同一解释器进程无法同时加载。
|
||||
"""
|
||||
34
tests/v2/test_agenttokens_plugin.py
Normal file
34
tests/v2/test_agenttokens_plugin.py
Normal 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 管理"
|
||||
Reference in New Issue
Block a user