Files
MoviePilot/tests/test_plugin_instance_enablement.py
Aqr-K d44480d739 Merge pull request #6666 from Aqr-K/feat/plugin-instance-target-enablement
feat(plugin): 插件实例默认调用目标与启停,未指定实例时不再兜底取第一个
2026-09-13 16:37:27 +08:00

300 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""插件实例启停语义测试:数据访问层、实例存储与运行期装载取数。"""
from __future__ import annotations
import pytest
from sqlalchemy import delete
from sqlalchemy.orm import Session
from app.db.models.plugininstance import PluginInstance as PluginInstanceRecord
from app.db.oper.plugininstance import PluginInstanceOper
from app.db.uow import run_sync_transaction
from app.runtime.extensions.plugin.storage import PluginInstanceStore, PluginStorage
from app.schemas.plugin import PluginInstance
_INSTANCE_IDS = ("EnablementHost", "EnablementHostX2", "EnablementOther")
@pytest.fixture(autouse=True)
def purge_written_rows():
"""删除用例写入的行,测试库在整个会话内共享,残留会干扰后续用例。"""
yield
def purge(session: Session) -> None:
"""按本文件使用的固定标识精确删除,不触碰其他用例的数据。"""
session.execute(
delete(PluginInstanceRecord).where(
PluginInstanceRecord.instance_id.in_(_INSTANCE_IDS)
)
)
run_sync_transaction(purge)
# --------------------------------------------------------------------------- #
# PluginInstanceOper.set_enabled
# --------------------------------------------------------------------------- #
def test_set_enabled_keeps_configuration_when_disabling():
"""停用只翻启用位,业务参数与展示信息原样留在那一行等待再次启用。
这正是这一列存在的理由:把「在册」与「有配置」拆开,让用户能停掉一个实例而
不必在「删掉配置」和「留着它继续跑」之间二选一。
"""
oper = PluginInstanceOper()
oper.save(
instance_id="EnablementHostX2",
source_plugin_id="EnablementHost",
plugin_name="分身显示名",
is_enabled=True,
)
oper.save_config_data(
instance_id="EnablementHostX2",
source_plugin_id="EnablementHost",
config_data={"token": "kept"},
)
assert oper.set_enabled(instance_id="EnablementHostX2", is_enabled=False) is True
record = oper.get("EnablementHostX2")
assert record is not None
assert record.is_enabled is False
assert record.config_data == {"token": "kept"}
assert record.plugin_name == "分身显示名"
# 再次启用即恢复,配置不需要重填
assert oper.set_enabled(instance_id="EnablementHostX2", is_enabled=True) is True
restored = oper.get("EnablementHostX2")
assert restored is not None
assert restored.is_enabled is True
assert restored.config_data == {"token": "kept"}
def test_disabling_clears_default_target_and_log_level_override():
"""停用同时清掉默认调用目标置位与日志等级覆盖,两者只对在册实例才有意义。
不清默认目标,未指定实例的外部调用会被路由到一个不会被实例化的实例;不清日志
等级,运行期停用时清掉的进程内覆盖与库里留下的那份会长期不一致。
"""
oper = PluginInstanceOper()
oper.save(instance_id="EnablementHost", source_plugin_id="EnablementHost", is_enabled=True)
oper.save(
instance_id="EnablementHostX2",
source_plugin_id="EnablementHost",
is_enabled=True,
)
oper.set_default_target("EnablementHost", "EnablementHostX2")
oper.set_log_level(
instance_id="EnablementHostX2",
log_level="DEBUG",
log_expires_at=None,
)
oper.set_enabled(instance_id="EnablementHostX2", is_enabled=False)
record = oper.get("EnablementHostX2")
assert record is not None
assert record.is_default_target is False
assert record.log_level is None
assert record.log_expires_at is None
def test_set_enabled_reports_false_for_a_row_that_does_not_exist():
"""行不存在时如实返回失败,不隐式建行。"""
oper = PluginInstanceOper()
assert oper.set_enabled(instance_id="EnablementMissing", is_enabled=True) is False
assert oper.get("EnablementMissing") is None
def test_list_enabled_excludes_disabled_rows():
"""运行期装载取数只看启用中的行。"""
oper = PluginInstanceOper()
oper.save(instance_id="EnablementHost", source_plugin_id="EnablementHost", is_enabled=True)
oper.save(
instance_id="EnablementHostX2",
source_plugin_id="EnablementHost",
is_enabled=False,
)
enabled = {record.instance_id for record in oper.list_enabled()}
listed = {record.instance_id for record in oper.list_by_source("EnablementHost")}
assert "EnablementHost" in enabled
assert "EnablementHostX2" not in enabled
# 停用的行仍然在册:卡片要看得见、卸载守卫要拦得住
assert listed == {"EnablementHost", "EnablementHostX2"}
def test_an_enabled_host_row_is_never_recycled_as_empty():
"""启用中的本体行不算「只剩身份列」,清空配置不得把它连同装载判据一起回收。"""
oper = PluginInstanceOper()
oper.save(instance_id="EnablementHost", source_plugin_id="EnablementHost", is_enabled=True)
oper.save_config_data(
instance_id="EnablementHost",
source_plugin_id="EnablementHost",
config_data={"a": 1},
)
oper.clear_config_data("EnablementHost")
record = oper.get("EnablementHost")
assert record is not None, "启用中的本体行被当成空行回收,该插件将永不加载"
assert record.is_enabled is True
# --------------------------------------------------------------------------- #
# PluginInstanceStore分身与本体共用一个开关
# --------------------------------------------------------------------------- #
class _FakeDirectory:
"""在内存里模拟实例表读写的目录替身。"""
def __init__(self, records: dict[str, PluginInstance]) -> None:
self.records = records
def get(self, instance_id: str):
"""按实例 ID 读取单行。"""
return self.records.get(instance_id)
def list_all(self):
"""列出全部行,含停用的。"""
return list(self.records.values())
def list_by_source(self, source_plugin_id: str):
"""按源插件列出全部行。"""
return [
record
for record in self.records.values()
if record.source_plugin_id == source_plugin_id
]
def list_enabled(self):
"""列出启用中的行。"""
return [record for record in self.records.values() if record.is_enabled]
def save(self, instance: PluginInstance) -> None:
"""新增或更新一行。"""
self.records[instance.instance_id] = instance
def delete(self, instance_id: str) -> bool:
"""删除一行。"""
return self.records.pop(instance_id, None) is not None
def set_enabled(self, instance_id: str, is_enabled: bool) -> bool:
"""写入启用位。"""
record = self.records.get(instance_id)
if record is None:
return False
self.records[instance_id] = record.model_copy(update={"is_enabled": is_enabled})
return True
def _store(records: dict[str, PluginInstance]) -> tuple[PluginInstanceStore, _FakeDirectory]:
"""构造挂接内存目录、且旧键导入已标记完成的实例存储。"""
directory = _FakeDirectory(records)
storage = PluginStorage(read=lambda _key: True, write=lambda _key, _value: None)
return (
PluginInstanceStore(storage=lambda: storage, directory=lambda: directory),
directory,
)
def _clone(instance_id: str, source: str, *, enabled: bool = True) -> PluginInstance:
"""构造一个分身实例描述。"""
return PluginInstance(
instance_id=instance_id, source_plugin_id=source, is_enabled=enabled
)
def _host(plugin_id: str, *, enabled: bool = True) -> PluginInstance:
"""构造一个本体实例描述。"""
return PluginInstance(
instance_id=plugin_id, source_plugin_id=plugin_id, is_enabled=enabled
)
def test_enabled_hosts_is_the_host_loading_criterion():
"""本体的装载取数只给出启用中的本体,停用的本体不在其中。"""
store, _ = _store(
{
"PluginA": _host("PluginA"),
"PluginB": _host("PluginB", enabled=False),
"PluginAx2": _clone("PluginAx2", "PluginA"),
}
)
assert set(store.enabled_hosts()) == {"PluginA"}
# 分身不混进本体取数
assert set(store.enabled()) == {"PluginAx2"}
def test_disabled_clone_stays_registered_and_visible():
"""停用的分身仍在册:卡片列表、卸载守卫与默认目标候选都要看得见它。
读取口若替调用方把停用的行藏起来,卸载守卫会放过一个还留着行的分身,用户随后
只能看到一个既列不出来、也删不掉的孤儿实例。
"""
store, _ = _store(
{"PluginA": _host("PluginA"), "PluginAx2": _clone("PluginAx2", "PluginA")}
)
assert store.disable("PluginAx2") is True
assert set(store.all()) == {"PluginAx2"}
assert [record.instance_id for record in store.for_source("PluginA")] == ["PluginAx2"]
assert store.get("PluginAx2") is not None
assert set(store.enabled()) == set()
def test_enable_and_disable_report_whether_state_changed():
"""重复启用或重复停用报告未发生变化,调用方据此决定要不要重载运行态。"""
store, _ = _store(
{"PluginA": _host("PluginA"), "PluginAx2": _clone("PluginAx2", "PluginA")}
)
assert store.disable("PluginAx2") is True
assert store.disable("PluginAx2") is False
assert store.enable("PluginAx2") is True
assert store.enable("PluginAx2") is False
def test_enable_host_creates_the_row_when_the_plugin_has_none():
"""安装收尾时本体还没有行,必须按默认视图建出并置为启用。
只往安装清单里加一条而不建这一行,插件会装完当次靠定向重载跑起来、重启后再也
不加载。
"""
store, directory = _store({})
assert store.enable_host("PluginA") is True
record = directory.records["PluginA"]
assert record.is_host is True
assert record.is_enabled is True
assert store.enable_host("PluginA") is False
def test_disable_host_keeps_the_row_and_its_configuration():
"""停用本体保留整行,只翻启用位。"""
store, directory = _store({"PluginA": _host("PluginA")})
assert store.disable_host("PluginA") is True
assert store.disable_host("PluginA") is False
assert directory.records["PluginA"].is_enabled is False
assert store.get_host("PluginA") is not None
def test_host_and_clone_views_never_leak_into_each_other():
"""本体与分身两组读写口互不可见,避免按分身写入顶掉本体那一行。"""
store, _ = _store(
{"PluginA": _host("PluginA"), "PluginAx2": _clone("PluginAx2", "PluginA")}
)
assert store.get("PluginA") is None
assert store.get_host("PluginAx2") is None
assert set(store.all_hosts()) == {"PluginA"}
assert set(store.all()) == {"PluginAx2"}