Files
MoviePilot/tests/test_plugin_default_target.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

416 lines
15 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 app.runtime.extensions.plugin.target import PluginDefaultTargetControl
from app.schemas.plugin import PluginInstance
class _Harness:
"""组装 PluginDefaultTargetControl 依赖并记录调用轨迹的测试脚手架。"""
def __init__(
self,
*,
host_instance: PluginInstance | None = None,
clones: list[PluginInstance] | None = None,
plugin_exists: bool = True,
running_ids: set[str] | None = None,
set_result: bool = True,
) -> None:
self.host_instance = host_instance
self.clones = list(clones or [])
self.plugin_exists_flag = plugin_exists
self.running_ids = set(running_ids or set())
self.saved_hosts: list[PluginInstance] = []
self.set_calls: list[tuple[str, str]] = []
self.clear_calls: list[str] = []
self._set_result = set_result
def _get_instance(self, instance_id: str) -> PluginInstance | None:
for clone in self.clones:
if clone.instance_id == instance_id:
return clone
return None
def _instances_for_source(self, source_plugin_id: str) -> list[PluginInstance]:
return [clone for clone in self.clones if clone.source_plugin_id == source_plugin_id]
def _get_host_instance(self, plugin_id: str) -> PluginInstance | None:
if self.host_instance is not None and self.host_instance.instance_id == plugin_id:
return self.host_instance
return None
def _save_host_instance(self, instance: PluginInstance) -> None:
self.saved_hosts.append(instance)
self.host_instance = instance
def _running(self) -> dict[str, object]:
return {instance_id: object() for instance_id in self.running_ids}
def _set_default_target(self, plugin_id: str, instance_id: str) -> bool:
self.set_calls.append((plugin_id, instance_id))
return self._set_result
def _clear_default_target(self, plugin_id: str) -> None:
self.clear_calls.append(plugin_id)
def build(self) -> PluginDefaultTargetControl:
"""构造挂接本脚手架全部端口的裁决与置位控制器。"""
return PluginDefaultTargetControl(
plugin_exists=lambda _plugin_id: self.plugin_exists_flag,
get_instance=self._get_instance,
instances_for_source=self._instances_for_source,
get_host_instance=self._get_host_instance,
save_host_instance=self._save_host_instance,
running=self._running,
set_default_target=self._set_default_target,
clear_default_target=self._clear_default_target,
)
def _clone(instance_id: str, source_plugin_id: str, *, default: bool = False) -> PluginInstance:
"""构造一个分身实例描述。"""
return PluginInstance(
instance_id=instance_id,
source_plugin_id=source_plugin_id,
is_default_target=default,
)
def _host(plugin_id: str, *, default: bool = False) -> PluginInstance:
"""构造一个本体实例描述。"""
return PluginInstance(
instance_id=plugin_id,
source_plugin_id=plugin_id,
is_default_target=default,
)
# --------------------------------------------------------------------------- #
# resolve():单实例场景与显式实例直通
# --------------------------------------------------------------------------- #
def test_resolve_never_picks_the_first_instance_and_fails_deterministically():
"""有分身却未设默认目标时,重复调用必须次次报同一个错,绝不挑出任何实例。
这是本特性最核心的一条语义:随机挑或按登记顺序取第一个都会让同一次调用在
不同时刻落到不同实例,用户既无法复现、也无从知道刚才是哪个实例执行的。
因此这里既断言「不返回任何候选」,也断言多次调用的失败完全一致——一个会
「取第一个」的实现只会在第一次调用时暴露,之后看起来同样稳定。
"""
harness = _Harness(
host_instance=_host("PluginA"),
clones=[
_clone("PluginAx2", "PluginA"),
_clone("PluginAx3", "PluginA"),
],
running_ids={"PluginA", "PluginAx2", "PluginAx3"},
)
control = harness.build()
messages = []
for _ in range(5):
with pytest.raises(LookupError) as excinfo:
control.resolve("PluginA")
messages.append(str(excinfo.value))
# 五次调用报出完全相同的错误,说明裁决不含任何随机或顺序依赖的挑选
assert len(set(messages)) == 1
assert "未设置默认实例" in messages[0]
# 没有任何一次调用把候选当成结果返回:三个候选都只出现在「可选实例」清单里
assert harness.set_calls == []
assert harness.clear_calls == []
def test_resolve_returns_plugin_id_when_no_clones_exist():
"""只有本体、没有任何分身时直接返回插件 ID不要求设置默认目标。"""
harness = _Harness(host_instance=_host("PluginA"), running_ids={"PluginA"})
assert harness.build().resolve("PluginA") == "PluginA"
def test_resolve_returns_argument_unchanged_when_it_is_already_a_clone_id():
"""传入的标识本身就是某个分身的实例 ID 时原样返回,该分身没有自己的下级分身。"""
harness = _Harness(
host_instance=_host("PluginA"),
clones=[_clone("PluginAx2", "PluginA")],
running_ids={"PluginAx2"},
)
assert harness.build().resolve("PluginAx2") == "PluginAx2"
# --------------------------------------------------------------------------- #
# resolve():有分身时的默认目标裁决
# --------------------------------------------------------------------------- #
def test_resolve_uses_enabled_default_target_among_clones():
"""已有分身且默认目标已启用时,未指定实例的调用落到该默认目标。"""
harness = _Harness(
host_instance=_host("PluginA"),
clones=[
_clone("PluginAx2", "PluginA", default=True),
_clone("PluginAx3", "PluginA"),
],
running_ids={"PluginAx2", "PluginAx3"},
)
assert harness.build().resolve("PluginA") == "PluginAx2"
def test_resolve_host_itself_can_be_default_target():
"""本体同样可以被选为默认调用目标,与分身地位相同。"""
harness = _Harness(
host_instance=_host("PluginA", default=True),
clones=[_clone("PluginAx2", "PluginA")],
running_ids={"PluginA", "PluginAx2"},
)
assert harness.build().resolve("PluginA") == "PluginA"
def test_resolve_raises_when_no_default_target_set():
"""已有分身但未设置默认目标时报错,且不得回退到任何一个候选。"""
harness = _Harness(
host_instance=_host("PluginA"),
clones=[_clone("PluginAx2", "PluginA"), _clone("PluginAx3", "PluginA")],
running_ids={"PluginA", "PluginAx2", "PluginAx3"},
)
with pytest.raises(LookupError) as excinfo:
harness.build().resolve("PluginA")
message = str(excinfo.value)
assert "PluginA" in message
assert "未设置默认实例" in message
assert "PluginA已启用" in message
assert "PluginAx2已启用" in message
assert "PluginAx3已启用" in message
def test_resolve_raises_when_default_target_disabled_and_does_not_fall_back():
"""默认目标已停用时必须报错,不得静默改走另一个正在运行的实例。"""
harness = _Harness(
host_instance=_host("PluginA"),
clones=[
_clone("PluginAx2", "PluginA", default=True),
_clone("PluginAx3", "PluginA"),
],
running_ids={"PluginAx3"},
)
with pytest.raises(LookupError) as excinfo:
harness.build().resolve("PluginA")
message = str(excinfo.value)
assert "默认实例 PluginAx2 已停用" in message
assert "PluginAx3已启用" in message
def test_resolve_candidate_description_orders_alphabetically():
"""候选实例描述按实例 ID 升序排列,报错文案稳定可预期。"""
harness = _Harness(
host_instance=_host("PluginZ"),
clones=[_clone("PluginZb", "PluginZ"), _clone("PluginZa", "PluginZ")],
running_ids=set(),
)
with pytest.raises(LookupError) as excinfo:
harness.build().resolve("PluginZ")
assert str(excinfo.value).endswith(
"可选实例PluginZ已停用、PluginZa已停用、PluginZb已停用"
)
def test_resolve_treats_never_persisted_host_as_a_candidate():
"""本体从未被显式绑定过任何设置时,仍以默认视图参与候选而不是被略去。"""
harness = _Harness(
host_instance=None,
clones=[_clone("PluginAx2", "PluginA")],
running_ids={"PluginA"},
)
with pytest.raises(LookupError) as excinfo:
harness.build().resolve("PluginA")
assert "PluginA已启用" in str(excinfo.value)
# --------------------------------------------------------------------------- #
# set_target()
# --------------------------------------------------------------------------- #
def test_set_target_upserts_never_persisted_host_before_setting():
"""本体从未落盘过时,设为默认目标前先落盘一条默认视图记录。"""
harness = _Harness(host_instance=None)
result = harness.build().set_target("PluginA", "PluginA")
assert result is True
assert len(harness.saved_hosts) == 1
assert harness.saved_hosts[0].instance_id == "PluginA"
assert harness.set_calls == [("PluginA", "PluginA")]
def test_set_target_does_not_resave_already_persisted_host():
"""本体已经落盘过时不重复保存,只转发置位调用。"""
harness = _Harness(host_instance=_host("PluginA"))
harness.build().set_target("PluginA", "PluginA")
assert harness.saved_hosts == []
assert harness.set_calls == [("PluginA", "PluginA")]
def test_set_target_delegates_clone_to_atomic_callable():
"""目标是已归属该插件的分身时,直接转发给原子置位端口。"""
harness = _Harness(
host_instance=_host("PluginA"),
clones=[_clone("PluginAx2", "PluginA")],
)
result = harness.build().set_target("PluginA", "PluginAx2")
assert result is True
assert harness.set_calls == [("PluginA", "PluginAx2")]
def test_set_target_rejects_instance_not_belonging_to_plugin():
"""目标实例不存在或归属另一个插件时拒绝,且不下发到原子置位端口。"""
harness = _Harness(
host_instance=_host("PluginA"),
clones=[_clone("PluginBx2", "PluginB")],
)
result = harness.build().set_target("PluginA", "PluginBx2")
assert result is False
assert harness.set_calls == []
def test_set_target_propagates_atomic_callable_failure():
"""原子置位端口报告目标不存在时如实透传,不伪装成功。"""
harness = _Harness(
host_instance=_host("PluginA"),
clones=[_clone("PluginAx2", "PluginA")],
set_result=False,
)
assert harness.build().set_target("PluginA", "PluginAx2") is False
def test_set_target_raises_when_plugin_missing():
"""插件本身不存在时拒绝设置默认目标。"""
harness = _Harness(plugin_exists=False)
with pytest.raises(LookupError):
harness.build().set_target("Missing", "Missing")
# --------------------------------------------------------------------------- #
# clear_target()
# --------------------------------------------------------------------------- #
def test_clear_target_clears_when_matching_current_default():
"""请求清除的实例正是当前默认目标时才真正清除。"""
harness = _Harness(
host_instance=_host("PluginA"),
clones=[_clone("PluginAx2", "PluginA", default=True)],
)
harness.build().clear_target("PluginA", "PluginAx2")
assert harness.clear_calls == ["PluginA"]
def test_clear_target_is_noop_when_nothing_is_set():
"""插件当前没有任何默认目标置位时按空操作处理。"""
harness = _Harness(
host_instance=_host("PluginA"),
clones=[_clone("PluginAx2", "PluginA")],
)
harness.build().clear_target("PluginA", "PluginAx2")
assert harness.clear_calls == []
def test_clear_target_does_not_touch_a_different_current_default():
"""请求清除的实例并非当前默认目标时,不得误清另一个实例的置位。"""
harness = _Harness(
host_instance=_host("PluginA"),
clones=[
_clone("PluginAx2", "PluginA", default=True),
_clone("PluginAx3", "PluginA"),
],
)
harness.build().clear_target("PluginA", "PluginAx3")
assert harness.clear_calls == []
def test_clear_target_raises_when_plugin_missing():
"""插件本身不存在时拒绝清除默认目标。"""
harness = _Harness(plugin_exists=False)
with pytest.raises(LookupError):
harness.build().clear_target("Missing", "Missing")
def test_resolve_treats_a_loaded_but_disabled_default_target_as_unavailable():
"""默认目标已加载但被用户停用时按「已停用」处理,要求显式指定实例。
界面上关掉启用开关只会重新 init_plugin实例仍留在运行表里只看运行表会把
停用实例当成可用目标选中,调用随后以「插件不存在」收场。
"""
class _Disabled:
"""模拟已加载但启用开关被关掉的实例。"""
@staticmethod
def get_state() -> bool:
"""返回停用状态。"""
return False
harness = _Harness(
host_instance=_host("PluginA"),
clones=[_clone("PluginAx2", "PluginA", default=True)],
running_ids={"PluginAx2"},
)
harness._running = lambda: {"PluginAx2": _Disabled()}
with pytest.raises(LookupError) as excinfo:
harness.build().resolve("PluginA")
assert "已停用" in str(excinfo.value)
def test_set_target_rejects_clone_own_id_as_plugin_id():
"""用分身自身实例 ID 当源插件 ID 置位必须拒绝,否则会把该行改写成本体记录。"""
harness = _Harness(clones=[_clone("PluginAx2", "PluginA")])
with pytest.raises(LookupError):
harness.build().set_target("PluginAx2", "PluginAx2")
assert harness.saved_hosts == []
assert harness.set_calls == []
def test_clear_target_rejects_clone_own_id_as_plugin_id():
"""清除置位同样拒绝分身自身实例 ID避免落到本体分支。"""
harness = _Harness(clones=[_clone("PluginAx2", "PluginA")])
with pytest.raises(LookupError):
harness.build().clear_target("PluginAx2", "PluginAx2")
assert harness.clear_calls == []