mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/jxxghp/MoviePilot.git
synced 2026-09-20 08:03:34 +08:00
416 lines
15 KiB
Python
416 lines
15 KiB
Python
"""插件默认调用目标裁决与置位/清除测试。"""
|
||
|
||
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 == []
|