From 75925415a3ec42e683e9199b47570bebcac186de Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Wed, 3 Jun 2026 10:51:53 +0800 Subject: [PATCH] test: add pytest scaffold and agenttokens plugin test (#1033) --- pytest.ini | 11 +++ tests/README.md | 44 +++++++++++ tests/__init__.py | 7 ++ tests/_bootstrap.py | 118 ++++++++++++++++++++++++++++ tests/conftest.py | 31 ++++++++ tests/run.py | 34 ++++++++ tests/v1/__init__.py | 5 ++ tests/v2/__init__.py | 4 + tests/v2/test_agenttokens_plugin.py | 34 ++++++++ 9 files changed, 288 insertions(+) create mode 100644 pytest.ini create mode 100644 tests/README.md create mode 100644 tests/__init__.py create mode 100644 tests/_bootstrap.py create mode 100644 tests/conftest.py create mode 100644 tests/run.py create mode 100644 tests/v1/__init__.py create mode 100644 tests/v2/__init__.py create mode 100644 tests/v2/test_agenttokens_plugin.py diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..eba6f48 --- /dev/null +++ b/pytest.ini @@ -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 分独立会话运行 diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..ba5103f --- /dev/null +++ b/tests/README.md @@ -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`), +并使用带后端依赖的解释器(如 `/.venv/bin/python`)。 + +```bash +# 全量(推荐入口):v1/v2 各自独立会话依次跑,命令行参数透传给 pytest +/.venv/bin/python tests/run.py + +# 也可按代单独跑(v1/v2 必须分会话,勿混跑) +/.venv/bin/python -m pytest tests/v2 +/.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__`,只测纯逻辑方法,避免依赖完整运行时。 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..c3fe91f --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,7 @@ +"""插件仓单测包。 + +测试统一置于仓库根 ``tests/`` 下并按 v1/v2 分治,刻意不放在插件目录内: +插件的本地同步与市场下发按整目录拷贝(``shutil.copytree``),任何放在 +``plugins//`` 或 ``plugins.v2//`` 内的测试都会被一并下发到运行时副本, +既污染线上插件目录,也可能在缺少后端依赖时影响加载。 +""" diff --git a/tests/_bootstrap.py b/tests/_bootstrap.py new file mode 100644 index 0000000..07a23d4 --- /dev/null +++ b/tests/_bootstrap.py @@ -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 或非同级布局覆盖); + 否则按工作区同级布局推导 ``/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 ``。与 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") diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..471ade1 --- /dev/null +++ b/tests/conftest.py @@ -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) diff --git a/tests/run.py b/tests/run.py new file mode 100644 index 0000000..eb098bd --- /dev/null +++ b/tests/run.py @@ -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) diff --git a/tests/v1/__init__.py b/tests/v1/__init__.py new file mode 100644 index 0000000..47bf4d4 --- /dev/null +++ b/tests/v1/__init__.py @@ -0,0 +1,5 @@ +"""v1 插件(plugins/)单测包。 + +当前工作区仅维护 v2 插件,本目录预留骨架、暂不承载用例。 +与 v2 分会话运行:v1/v2 存在同名插件包,同一解释器进程无法同时加载。 +""" diff --git a/tests/v2/__init__.py b/tests/v2/__init__.py new file mode 100644 index 0000000..2094153 --- /dev/null +++ b/tests/v2/__init__.py @@ -0,0 +1,4 @@ +"""v2 插件(plugins.v2/)单测包。 + +与 v1 分会话运行:v1/v2 存在同名插件包,同一解释器进程无法同时加载。 +""" diff --git a/tests/v2/test_agenttokens_plugin.py b/tests/v2/test_agenttokens_plugin.py new file mode 100644 index 0000000..fce4b75 --- /dev/null +++ b/tests/v2/test_agenttokens_plugin.py @@ -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 管理"