mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/jxxghp/MoviePilot.git
synced 2026-09-21 00:23:39 +08:00
300 lines
11 KiB
Python
300 lines
11 KiB
Python
"""插件实例启停语义测试:数据访问层、实例存储与运行期装载取数。"""
|
||
|
||
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"}
|