ForcePilot/backend/test/unit/channels/contract/plugin/test_plugin.py

1287 lines
43 KiB
Python
Raw Normal View History

"""yuxi.channels.contract.plugin 模块单元测试。
覆盖插件协议包入口与各子模块
- ``__init__.py`` ``__all__`` 导出完整性
- ``manifest.py`` 的枚举与 dataclassFailurePolicy / CredentialType / AcquireMethod /
PluginDependency / ResourceQuota / CredentialStrategy / ChannelManifest /
PluginManifest
- ``lifecycle.py`` 的枚举与 ProtocolLifecycleState / LifecycleHook /
LifecycleHookHandler
- ``extension_point.py`` 的枚举dataclass ProtocolConflictStrategy /
FailureStrategy / StageSlot / EventSubscription / ConfigSource / Stage
- ``capability.py`` dataclass ProtocolCapabilityDeclaration /
CapabilityProof / CapabilityProver
- ``entry.py`` Protocol 与类型别名PluginHost / CHANNEL_ENTRY
"""
from __future__ import annotations
import inspect
from collections.abc import Callable
from datetime import datetime
from enum import StrEnum
from typing import Any, Protocol, get_args, get_origin
from unittest.mock import MagicMock
import pytest
from yuxi.channels.contract.dtos.capability import ChannelCapabilities
from yuxi.channels.contract.dtos.channel import ChannelType
from yuxi.channels.contract.dtos.config import ConfigField
from yuxi.channels.contract.plugin import (
CHANNEL_ENTRY,
AcquireMethod,
CapabilityDeclaration,
CapabilityProof,
CapabilityProver,
ChannelManifest,
ConfigSource,
ConflictStrategy,
CredentialStrategy,
CredentialType,
EventSubscription,
FailurePolicy,
FailureStrategy,
LifecycleHook,
LifecycleHookHandler,
LifecycleState,
PluginDependency,
PluginHost,
PluginManifest,
ResourceQuota,
Stage,
StageSlot,
)
pytestmark = pytest.mark.unit
def _make_instance(protocol_cls: type[Protocol]) -> Any:
"""构造 Protocol 的最小实现实例。
Python 3.12 ``@runtime_checkable`` Protocol ``isinstance`` 不再把
未配置属性的 ``MagicMock`` 视为实现类本辅助函数为协议声明的数据属性
提供占位值并继承协议默认方法体用于测试协议默认行为
"""
attrs: dict[str, Any] = {}
for cls in protocol_cls.__mro__:
if cls is object:
continue
for name in getattr(cls, "__annotations__", {}):
if name.startswith("_") or name in attrs:
continue
if hasattr(cls, name) and callable(getattr(cls, name)):
continue
attrs[name] = None
impl_name = f"_{protocol_cls.__name__}Impl"
impl = type(impl_name, (protocol_cls,), attrs)
return impl()
# ─── 辅助工厂 ────────────────────────────────────────────────────────────
def _build_channel_manifest(**kwargs) -> ChannelManifest:
"""构造最小可用 ChannelManifest 实例,便于测试覆写字段。"""
defaults: dict = dict(
id="com.yuxi.channels.feishu",
name="feishu",
version="1.0.0",
channel_type=ChannelType("feishu"),
provides=("inbound", "outbound"),
entry_module="yuxi.channels.plugins.feishu.entry",
capabilities=ChannelCapabilities(),
config_schema=(ConfigField(key="app_id", type="str"),),
)
defaults.update(kwargs)
return ChannelManifest(**defaults)
# ─── __all__ 导出完整性 ──────────────────────────────────────────────────
@pytest.mark.unit
class TestPluginExports:
"""plugin 包 ``__all__`` 导出完整性。"""
def test_all_contains_expected_core_names(self):
# Arrange - 期望 __all__ 中包含的核心协议与值对象名称
from yuxi.channels.contract.plugin import __all__ as plugin_all
expected = {
# manifest
"AcquireMethod",
"ChannelManifest",
"ConfigField",
"CredentialStrategy",
"CredentialType",
"FailurePolicy",
"PluginDependency",
"PluginManifest",
"ResourceQuota",
# entry
"CHANNEL_ENTRY",
"PluginHost",
# capability
"CapabilityDeclaration",
"CapabilityProof",
"CapabilityProver",
# lifecycle
"LifecycleHook",
"LifecycleHookHandler",
"LifecycleState",
# extension_point
"ConflictStrategy",
"ConfigSource",
"EventSubscription",
"FailureStrategy",
"Stage",
"StageSlot",
}
# Act
# Assert
assert expected.issubset(set(plugin_all))
def test_all_contains_adapter_names(self):
# Arrange - 期望 __all__ 中包含的适配器协议名称(抽样)
from yuxi.channels.contract.plugin import __all__ as plugin_all
expected_adapters = {
"InboundAdapter",
"OutboundAdapter",
"SessionAdapter",
"RichMessageAdapter",
"StreamingAdapter",
"DirectoryAdapter",
"CommandAdapter",
"WizardAdapter",
"DoctorAdapter",
"WhitelistAdapter",
"ToolsAdapter",
"MessageOpsAdapter",
"StatusAdapter",
"MentionAdapter",
"IdentityResolverAdapter",
"LifecycleAdapter",
"LoginAdapter",
"ProbeableAdapter",
"ContentModerationAdapter",
"PullerAdapter",
"StreamConnectorAdapter",
"WebhookTestAdapter",
"AttachmentUploadAdapter",
}
# Act
# Assert
assert expected_adapters.issubset(set(plugin_all))
def test_all_names_importable(self):
# Arrange
from yuxi.channels.contract import plugin as mod
# Act
# Assert - __all__ 中每个名称必须可在模块属性中找到
for name in mod.__all__:
assert hasattr(mod, name), f"{name} 在 __all__ 但未定义"
def test_all_list_meets_minimum_count(self):
# Arrange - 当前 __all__ 至少 40 个名称23 适配器 + 17 协议/枚举/值对象)
from yuxi.channels.contract.plugin import __all__ as plugin_all
# Act
# Assert
assert len(plugin_all) >= 40
def test_all_names_unique(self):
# Arrange
from yuxi.channels.contract.plugin import __all__ as plugin_all
# Act
# Assert
assert len(plugin_all) == len(set(plugin_all))
# ─── manifest.py 枚举 ───────────────────────────────────────────────────
@pytest.mark.unit
class TestManifestEnums:
"""manifest.py 的 FailurePolicy / CredentialType / AcquireMethod 枚举。"""
def test_failure_policy_inherits_strenum(self):
# Arrange
# Act
# Assert
assert issubclass(FailurePolicy, StrEnum)
def test_failure_policy_values(self):
# Arrange
# Act
# Assert - 三个取值,字符串值与枚举名一致(小写)
assert FailurePolicy.DEGRADE == "degrade"
assert FailurePolicy.CIRCUIT_BREAK == "circuit_break"
assert FailurePolicy.ISOLATE == "isolate"
def test_failure_policy_member_count(self):
# Arrange
# Act
# Assert
assert len(list(FailurePolicy)) == 3
def test_failure_policy_str_value(self):
# Arrange - StrEnum 的 str() 返回值本身
# Act
# Assert
assert str(FailurePolicy.DEGRADE) == "degrade"
def test_credential_type_inherits_strenum(self):
# Arrange
# Act
# Assert
assert issubclass(CredentialType, StrEnum)
def test_credential_type_values(self):
# Arrange
# Act
# Assert
assert CredentialType.STATIC == "static"
assert CredentialType.DYNAMIC == "dynamic"
assert CredentialType.HYBRID == "hybrid"
def test_credential_type_member_count(self):
# Arrange
# Act
# Assert
assert len(list(CredentialType)) == 3
def test_acquire_method_inherits_strenum(self):
# Arrange
# Act
# Assert
assert issubclass(AcquireMethod, StrEnum)
def test_acquire_method_values(self):
# Arrange
# Act
# Assert
assert AcquireMethod.MANUAL == "manual"
assert AcquireMethod.QR_LOGIN == "qr_login"
assert AcquireMethod.OAUTH == "oauth"
assert AcquireMethod.WEBHOOK_VERIFY == "webhook_verify"
def test_acquire_method_member_count(self):
# Arrange
# Act
# Assert
assert len(list(AcquireMethod)) == 4
# ─── PluginDependency ───────────────────────────────────────────────────
@pytest.mark.unit
class TestPluginDependency:
"""PluginDependency 不可变值对象。"""
def test_required_fields_set(self):
# Arrange
# Act
dep = PluginDependency(plugin_id="p-1", version_range="^1.0.0")
# Assert
assert dep.plugin_id == "p-1"
assert dep.version_range == "^1.0.0"
def test_is_frozen_dataclass(self):
# Arrange
dep = PluginDependency(plugin_id="p-1", version_range="^1.0.0")
# Act & Assert - frozen dataclass 字段不可变
with pytest.raises(Exception):
dep.plugin_id = "p-2" # type: ignore[misc]
def test_version_range_immutable(self):
# Arrange
dep = PluginDependency(plugin_id="p-1", version_range="^1.0.0")
# Act & Assert
with pytest.raises(Exception):
dep.version_range = "^2.0.0" # type: ignore[misc]
# ─── ResourceQuota ──────────────────────────────────────────────────────
@pytest.mark.unit
class TestResourceQuota:
"""ResourceQuota 不可变值对象,所有字段可选。"""
def test_all_fields_default_none(self):
# Arrange
# Act
quota = ResourceQuota()
# Assert - 所有字段默认 None
assert quota.max_cpu is None
assert quota.max_memory is None
assert quota.max_connections is None
assert quota.max_calls_per_sec is None
def test_fields_settable(self):
# Arrange
# Act
quota = ResourceQuota(
max_cpu="500m",
max_memory="512Mi",
max_connections=100,
max_calls_per_sec=50,
)
# Assert
assert quota.max_cpu == "500m"
assert quota.max_memory == "512Mi"
assert quota.max_connections == 100
assert quota.max_calls_per_sec == 50
def test_is_frozen_dataclass(self):
# Arrange
quota = ResourceQuota(max_cpu="500m")
# Act & Assert
with pytest.raises(Exception):
quota.max_cpu = "1000m" # type: ignore[misc]
def test_max_connections_immutable(self):
# Arrange
quota = ResourceQuota(max_connections=10)
# Act & Assert
with pytest.raises(Exception):
quota.max_connections = 20 # type: ignore[misc]
# ─── CredentialStrategy ─────────────────────────────────────────────────
@pytest.mark.unit
class TestCredentialStrategy:
"""CredentialStrategy 不可变值对象。"""
def test_required_fields_set(self):
# Arrange
# Act
strategy = CredentialStrategy(
type=CredentialType.STATIC,
acquire_method=AcquireMethod.MANUAL,
required_fields=("app_id", "app_secret"),
)
# Assert
assert strategy.type is CredentialType.STATIC
assert strategy.acquire_method is AcquireMethod.MANUAL
assert strategy.required_fields == ("app_id", "app_secret")
def test_optional_fields_defaults(self):
# Arrange
# Act
strategy = CredentialStrategy(
type=CredentialType.DYNAMIC,
acquire_method=AcquireMethod.QR_LOGIN,
required_fields=("token",),
)
# Assert - optional_fields 默认空元组supports_rotation/supports_revocation 默认 False
assert strategy.optional_fields == ()
assert strategy.supports_rotation is False
assert strategy.supports_revocation is False
def test_optional_fields_settable(self):
# Arrange
# Act
strategy = CredentialStrategy(
type=CredentialType.HYBRID,
acquire_method=AcquireMethod.OAUTH,
required_fields=("app_id",),
optional_fields=("user_token",),
supports_rotation=True,
supports_revocation=True,
)
# Assert
assert strategy.optional_fields == ("user_token",)
assert strategy.supports_rotation is True
assert strategy.supports_revocation is True
def test_is_frozen_dataclass(self):
# Arrange
strategy = CredentialStrategy(
type=CredentialType.STATIC,
acquire_method=AcquireMethod.MANUAL,
required_fields=("app_id",),
)
# Act & Assert
with pytest.raises(Exception):
strategy.type = CredentialType.DYNAMIC # type: ignore[misc]
def test_required_fields_immutable(self):
# Arrange
strategy = CredentialStrategy(
type=CredentialType.STATIC,
acquire_method=AcquireMethod.MANUAL,
required_fields=("app_id",),
)
# Act & Assert
with pytest.raises(Exception):
strategy.required_fields = ("other",) # type: ignore[misc]
# ─── ChannelManifest ────────────────────────────────────────────────────
@pytest.mark.unit
class TestChannelManifest:
"""ChannelManifest 不可变值对象。"""
def test_required_fields_set(self):
# Arrange
# Act
manifest = _build_channel_manifest()
# Assert
assert manifest.id == "com.yuxi.channels.feishu"
assert manifest.name == "feishu"
assert manifest.version == "1.0.0"
assert manifest.channel_type == ChannelType("feishu")
assert manifest.provides == ("inbound", "outbound")
assert manifest.entry_module == "yuxi.channels.plugins.feishu.entry"
assert isinstance(manifest.capabilities, ChannelCapabilities)
assert isinstance(manifest.config_schema, tuple)
assert isinstance(manifest.config_schema[0], ConfigField)
def test_lifecycle_default(self):
# Arrange
# Act
manifest = _build_channel_manifest()
# Assert - 默认生命周期为 init/start/stop/unload
assert manifest.lifecycle == ("init", "start", "stop", "unload")
def test_failure_policy_default(self):
# Arrange
# Act
manifest = _build_channel_manifest()
# Assert
assert manifest.failure_policy is FailurePolicy.DEGRADE
def test_critical_default_false(self):
# Arrange
# Act
manifest = _build_channel_manifest()
# Assert
assert manifest.critical is False
def test_requires_dm_pairing_default_true(self):
# Arrange
# Act
manifest = _build_channel_manifest()
# Assert
assert manifest.requires_dm_pairing is True
def test_requires_outbound_delivery_default_true(self):
# Arrange
# Act
manifest = _build_channel_manifest()
# Assert
assert manifest.requires_outbound_delivery is True
def test_credential_strategy_default_none(self):
# Arrange
# Act
manifest = _build_channel_manifest()
# Assert
assert manifest.credential_strategy is None
def test_depends_default_empty_tuple(self):
# Arrange
# Act
manifest = _build_channel_manifest()
# Assert
assert manifest.depends == ()
def test_compatibility_default_none(self):
# Arrange
# Act
manifest = _build_channel_manifest()
# Assert
assert manifest.compatibility is None
def test_resource_quota_default_none(self):
# Arrange
# Act
manifest = _build_channel_manifest()
# Assert
assert manifest.resource_quota is None
def test_accessible_ports_default_empty_tuple(self):
# Arrange
# Act
manifest = _build_channel_manifest()
# Assert
assert manifest.accessible_ports == ()
def test_injectable_pipelines_default_empty_tuple(self):
# Arrange
# Act
manifest = _build_channel_manifest()
# Assert
assert manifest.injectable_pipelines == ()
def test_skills_default_empty_tuple(self):
# Arrange
# Act
manifest = _build_channel_manifest()
# Assert
assert manifest.skills == ()
def test_env_vars_default_empty_tuple(self):
# Arrange
# Act
manifest = _build_channel_manifest()
# Assert
assert manifest.env_vars == ()
def test_credential_strategy_settable(self):
# Arrange
strategy = CredentialStrategy(
type=CredentialType.STATIC,
acquire_method=AcquireMethod.MANUAL,
required_fields=("app_id",),
)
# Act
manifest = _build_channel_manifest(credential_strategy=strategy)
# Assert
assert manifest.credential_strategy is strategy
def test_is_frozen_dataclass(self):
# Arrange
manifest = _build_channel_manifest()
# Act & Assert
with pytest.raises(Exception):
manifest.id = "other" # type: ignore[misc]
def test_critical_immutable(self):
# Arrange
manifest = _build_channel_manifest()
# Act & Assert
with pytest.raises(Exception):
manifest.critical = True # type: ignore[misc]
# ─── PluginManifest ─────────────────────────────────────────────────────
@pytest.mark.unit
class TestPluginManifest:
"""PluginManifest 不可变值对象。"""
def test_required_field_manifest(self):
# Arrange
channel_manifest = _build_channel_manifest()
# Act
plugin_manifest = PluginManifest(manifest=channel_manifest)
# Assert
assert plugin_manifest.manifest is channel_manifest
def test_adapters_default_empty_tuple(self):
# Arrange
# Act
plugin_manifest = PluginManifest(manifest=_build_channel_manifest())
# Assert
assert plugin_manifest.adapters == ()
def test_stages_default_empty_tuple(self):
# Arrange
# Act
plugin_manifest = PluginManifest(manifest=_build_channel_manifest())
# Assert
assert plugin_manifest.stages == ()
def test_event_subscriptions_default_empty_tuple(self):
# Arrange
# Act
plugin_manifest = PluginManifest(manifest=_build_channel_manifest())
# Assert
assert plugin_manifest.event_subscriptions == ()
def test_config_sources_default_empty_tuple(self):
# Arrange
# Act
plugin_manifest = PluginManifest(manifest=_build_channel_manifest())
# Assert
assert plugin_manifest.config_sources == ()
def test_installed_at_default_none(self):
# Arrange
# Act
plugin_manifest = PluginManifest(manifest=_build_channel_manifest())
# Assert
assert plugin_manifest.installed_at is None
def test_install_source_default_none(self):
# Arrange
# Act
plugin_manifest = PluginManifest(manifest=_build_channel_manifest())
# Assert
assert plugin_manifest.install_source is None
def test_install_version_default_none(self):
# Arrange
# Act
plugin_manifest = PluginManifest(manifest=_build_channel_manifest())
# Assert
assert plugin_manifest.install_version is None
def test_optional_fields_settable(self):
# Arrange
now = datetime(2026, 1, 1, 0, 0, 0)
# Act
plugin_manifest = PluginManifest(
manifest=_build_channel_manifest(),
adapters=("InboundAdapter",),
stages=("stage-1",),
event_subscriptions=("PairingApproved",),
config_sources=("source-1",),
installed_at=now,
install_source="/path/to/plugin",
install_version="1.2.3",
)
# Assert
assert plugin_manifest.adapters == ("InboundAdapter",)
assert plugin_manifest.stages == ("stage-1",)
assert plugin_manifest.event_subscriptions == ("PairingApproved",)
assert plugin_manifest.config_sources == ("source-1",)
assert plugin_manifest.installed_at == now
assert plugin_manifest.install_source == "/path/to/plugin"
assert plugin_manifest.install_version == "1.2.3"
def test_is_frozen_dataclass(self):
# Arrange
plugin_manifest = PluginManifest(manifest=_build_channel_manifest())
# Act & Assert
with pytest.raises(Exception):
plugin_manifest.manifest = _build_channel_manifest(id="other") # type: ignore[misc]
# ─── lifecycle.py 枚举 ──────────────────────────────────────────────────
@pytest.mark.unit
class TestLifecycleEnums:
"""lifecycle.py 的 LifecycleState / LifecycleHook 枚举。"""
def test_lifecycle_state_inherits_strenum(self):
# Arrange
# Act
# Assert
assert issubclass(LifecycleState, StrEnum)
def test_lifecycle_state_member_count(self):
# Arrange
# Act
# Assert - 11 个状态
assert len(list(LifecycleState)) == 11
def test_lifecycle_state_values(self):
# Arrange
# Act
# Assert
assert LifecycleState.DISCOVERED == "discovered"
assert LifecycleState.RESOLVED == "resolved"
assert LifecycleState.LOADED == "loaded"
assert LifecycleState.INITIALIZED == "initialized"
assert LifecycleState.STARTED == "started"
assert LifecycleState.PAUSED == "paused"
assert LifecycleState.STOPPED == "stopped"
assert LifecycleState.UNLOADED == "unloaded"
assert LifecycleState.FAILED == "failed"
assert LifecycleState.NOT_INSTALLED == "not_installed"
assert LifecycleState.INSTALLED == "installed"
def test_lifecycle_hook_inherits_strenum(self):
# Arrange
# Act
# Assert
assert issubclass(LifecycleHook, StrEnum)
def test_lifecycle_hook_member_count(self):
# Arrange
# Act
# Assert - 8 个钩子
assert len(list(LifecycleHook)) == 8
def test_lifecycle_hook_values(self):
# Arrange
# Act
# Assert
assert LifecycleHook.INIT == "init"
assert LifecycleHook.START == "start"
assert LifecycleHook.STOP == "stop"
assert LifecycleHook.PAUSE == "pause"
assert LifecycleHook.RESUME == "resume"
assert LifecycleHook.UNLOAD == "unload"
assert LifecycleHook.RECONFIGURE == "reconfigure"
assert LifecycleHook.FAIL == "fail"
# ─── LifecycleHookHandler Protocol ──────────────────────────────────────
@pytest.mark.unit
class TestLifecycleHookHandlerProtocol:
"""LifecycleHookHandler 为 runtime_checkable Protocol。"""
def test_is_protocol_subclass(self):
# Arrange
# Act
# Assert
assert issubclass(LifecycleHookHandler, Protocol)
def test_runtime_checkable_isinstance_works(self):
# Arrange - 使用最小实现实例替代 MagicMock兼容 Python 3.12
# runtime_checkable Protocol 的 isinstance 行为
# Act
# Assert
assert isinstance(_make_instance(LifecycleHookHandler), LifecycleHookHandler)
def test_protocol_has_expected_methods(self):
# Arrange - LifecycleHookHandler 定义了 8 个异步钩子方法
expected_methods = [
"onInit",
"onStart",
"onStop",
"onPause",
"onResume",
"onUnload",
"onReconfigure",
"onFail",
]
# Act
# Assert
for name in expected_methods:
assert hasattr(LifecycleHookHandler, name), f"缺少方法 {name}"
def test_onReconfigure_signature_has_old_config_and_new_config(self):
# Arrange - F-08 签名扩展onReconfigure 接收关键字参数 old_config / new_config
# Act
sig = inspect.signature(LifecycleHookHandler.onReconfigure)
params = sig.parameters
# Assert - 包含 old_config 与 new_config 参数
assert "old_config" in params, "onReconfigure 缺少 old_config 参数"
assert "new_config" in params, "onReconfigure 缺少 new_config 参数"
# old_config 默认 None向后兼容new_config 必填
assert params["old_config"].default is None
assert params["new_config"].default is inspect.Parameter.empty
# 两者均为关键字参数KEYWORD_ONLY框架通过关键字传递
assert params["old_config"].kind is inspect.Parameter.KEYWORD_ONLY
assert params["new_config"].kind is inspect.Parameter.KEYWORD_ONLY
# ─── extension_point.py 枚举 ────────────────────────────────────────────
@pytest.mark.unit
class TestExtensionPointEnums:
"""extension_point.py 的 ConflictStrategy / FailureStrategy 枚举。"""
def test_conflict_strategy_inherits_strenum(self):
# Arrange
# Act
# Assert
assert issubclass(ConflictStrategy, StrEnum)
def test_conflict_strategy_member_count(self):
# Arrange
# Act
# Assert - 3 个冲突策略
assert len(list(ConflictStrategy)) == 3
def test_conflict_strategy_values(self):
# Arrange
# Act
# Assert
assert ConflictStrategy.CHAIN == "chain"
assert ConflictStrategy.REJECT == "reject"
assert ConflictStrategy.OVERRIDE == "override"
def test_failure_strategy_inherits_strenum(self):
# Arrange
# Act
# Assert
assert issubclass(FailureStrategy, StrEnum)
def test_failure_strategy_member_count(self):
# Arrange
# Act
# Assert - 4 个阶段失败策略
assert len(list(FailureStrategy)) == 4
def test_failure_strategy_values(self):
# Arrange
# Act
# Assert
assert FailureStrategy.TERMINATE == "terminate"
assert FailureStrategy.SKIP == "skip"
assert FailureStrategy.COMPENSATE == "compensate"
assert FailureStrategy.DEGRADE == "degrade"
# ─── StageSlot ──────────────────────────────────────────────────────────
@pytest.mark.unit
class TestStageSlot:
"""StageSlot 不可变值对象。"""
def test_required_fields_set(self):
# Arrange - stage 字段为 Stage Protocol 实例,使用 MagicMock 模拟
stage = MagicMock()
# Act
slot = StageSlot(pipeline="inbound", anchor="before:route", stage=stage)
# Assert
assert slot.pipeline == "inbound"
assert slot.anchor == "before:route"
assert slot.stage is stage
def test_priority_default_100(self):
# Arrange
# Act
slot = StageSlot(pipeline="inbound", anchor="before:route", stage=MagicMock())
# Assert
assert slot.priority == 100
def test_multi_default_false(self):
# Arrange
# Act
slot = StageSlot(pipeline="inbound", anchor="before:route", stage=MagicMock())
# Assert
assert slot.multi is False
def test_conflict_strategy_default_chain(self):
# Arrange
# Act
slot = StageSlot(pipeline="inbound", anchor="before:route", stage=MagicMock())
# Assert
assert slot.conflict_strategy is ConflictStrategy.CHAIN
def test_failure_policy_default_degrade(self):
# Arrange
# Act
slot = StageSlot(pipeline="inbound", anchor="before:route", stage=MagicMock())
# Assert
assert slot.failure_policy is FailurePolicy.DEGRADE
def test_optional_fields_settable(self):
# Arrange
# Act
slot = StageSlot(
pipeline="outbound",
anchor="after:format",
stage=MagicMock(),
priority=50,
multi=True,
conflict_strategy=ConflictStrategy.REJECT,
failure_policy=FailurePolicy.ISOLATE,
)
# Assert
assert slot.priority == 50
assert slot.multi is True
assert slot.conflict_strategy is ConflictStrategy.REJECT
assert slot.failure_policy is FailurePolicy.ISOLATE
def test_is_frozen_dataclass(self):
# Arrange
slot = StageSlot(pipeline="inbound", anchor="before:route", stage=MagicMock())
# Act & Assert
with pytest.raises(Exception):
slot.pipeline = "outbound" # type: ignore[misc]
def test_priority_immutable(self):
# Arrange
slot = StageSlot(pipeline="inbound", anchor="before:route", stage=MagicMock())
# Act & Assert
with pytest.raises(Exception):
slot.priority = 200 # type: ignore[misc]
# ─── EventSubscription ──────────────────────────────────────────────────
@pytest.mark.unit
class TestEventSubscription:
"""EventSubscription 不可变值对象。"""
def test_required_fields_set(self):
# Arrange - handler 字段为 EventHandler Protocol 实例,使用 MagicMock 模拟
handler = MagicMock()
# Act
sub = EventSubscription(event_type="PairingApproved", handler=handler)
# Assert
assert sub.event_type == "PairingApproved"
assert sub.handler is handler
def test_priority_default_100(self):
# Arrange
# Act
sub = EventSubscription(event_type="PairingApproved", handler=MagicMock())
# Assert
assert sub.priority == 100
def test_failure_policy_default_degrade(self):
# Arrange
# Act
sub = EventSubscription(event_type="PairingApproved", handler=MagicMock())
# Assert
assert sub.failure_policy is FailurePolicy.DEGRADE
def test_optional_fields_settable(self):
# Arrange
# Act
sub = EventSubscription(
event_type="ConfigChanged",
handler=MagicMock(),
priority=50,
failure_policy=FailurePolicy.CIRCUIT_BREAK,
)
# Assert
assert sub.priority == 50
assert sub.failure_policy is FailurePolicy.CIRCUIT_BREAK
def test_is_frozen_dataclass(self):
# Arrange
sub = EventSubscription(event_type="PairingApproved", handler=MagicMock())
# Act & Assert
with pytest.raises(Exception):
sub.event_type = "other" # type: ignore[misc]
# ─── ConfigSource ───────────────────────────────────────────────────────
@pytest.mark.unit
class TestConfigSource:
"""ConfigSource 不可变值对象。"""
def test_required_fields_set(self):
# Arrange - loader 字段为 ConfigLoader Protocol 实例,使用 MagicMock 模拟
loader = MagicMock()
# Act
source = ConfigSource(source_id="src-1", loader=loader)
# Assert
assert source.source_id == "src-1"
assert source.loader is loader
def test_decryptor_default_none(self):
# Arrange
# Act
source = ConfigSource(source_id="src-1", loader=MagicMock())
# Assert
assert source.decryptor is None
def test_failure_policy_default_degrade(self):
# Arrange
# Act
source = ConfigSource(source_id="src-1", loader=MagicMock())
# Assert
assert source.failure_policy is FailurePolicy.DEGRADE
def test_optional_fields_settable(self):
# Arrange
# Act
source = ConfigSource(
source_id="src-1",
loader=MagicMock(),
decryptor=MagicMock(),
failure_policy=FailurePolicy.ISOLATE,
)
# Assert
assert source.decryptor is not None
assert source.failure_policy is FailurePolicy.ISOLATE
def test_is_frozen_dataclass(self):
# Arrange
source = ConfigSource(source_id="src-1", loader=MagicMock())
# Act & Assert
with pytest.raises(Exception):
source.source_id = "other" # type: ignore[misc]
# ─── Stage Protocol ─────────────────────────────────────────────────────
@pytest.mark.unit
class TestStageProtocol:
"""Stage 为 runtime_checkable Protocol。"""
def test_is_protocol_subclass(self):
# Arrange
# Act
# Assert
assert issubclass(Stage, Protocol)
def test_runtime_checkable_isinstance_works(self):
# Arrange - 使用最小实现实例替代 MagicMock兼容 Python 3.12
# runtime_checkable Protocol 的 isinstance 行为
# Act
# Assert
assert isinstance(_make_instance(Stage), Stage)
def test_protocol_has_expected_attributes(self):
# Arrange - Stage 定义了 id/reads/writes/idempotent/thread_safe/failure/compensate 属性
expected_attrs = [
"id",
"reads",
"writes",
"idempotent",
"thread_safe",
"failure",
"compensate",
"process",
]
# Act
annotations = getattr(Stage, "__annotations__", {})
# Assert: 数据属性在 __annotations__ 中声明process 为方法
for name in expected_attrs:
assert name in annotations or hasattr(Stage, name), f"Stage 缺少属性 {name}"
# ─── CapabilityDeclaration ──────────────────────────────────────────────
@pytest.mark.unit
class TestCapabilityDeclaration:
"""CapabilityDeclaration 不可变值对象。"""
def test_capabilities_field_set(self):
# Arrange
caps = ChannelCapabilities(rich_message=True, streaming=True)
# Act
decl = CapabilityDeclaration(capabilities=caps)
# Assert
assert decl.capabilities is caps
assert decl.capabilities.rich_message is True
assert decl.capabilities.streaming is True
def test_default_capabilities(self):
# Arrange
# Act
decl = CapabilityDeclaration(capabilities=ChannelCapabilities())
# Assert
assert isinstance(decl.capabilities, ChannelCapabilities)
assert decl.capabilities.rich_message is False
def test_is_frozen_dataclass(self):
# Arrange
decl = CapabilityDeclaration(capabilities=ChannelCapabilities())
# Act & Assert
with pytest.raises(Exception):
decl.capabilities = ChannelCapabilities() # type: ignore[misc]
# ─── CapabilityProof ────────────────────────────────────────────────────
@pytest.mark.unit
class TestCapabilityProof:
"""CapabilityProof 不可变值对象。"""
def test_required_fields_set(self):
# Arrange
now = datetime(2026, 1, 1, 0, 0, 0)
# Act
proof = CapabilityProof(
capability_name="streaming",
proven=True,
proof_timestamp=now,
)
# Assert
assert proof.capability_name == "streaming"
assert proof.proven is True
assert proof.proof_timestamp == now
def test_cache_ttl_default_300(self):
# Arrange
# Act
proof = CapabilityProof(
capability_name="streaming",
proven=False,
proof_timestamp=datetime(2026, 1, 1, 0, 0, 0),
)
# Assert
assert proof.cache_ttl == 300
def test_cache_ttl_settable(self):
# Arrange
# Act
proof = CapabilityProof(
capability_name="streaming",
proven=True,
proof_timestamp=datetime(2026, 1, 1, 0, 0, 0),
cache_ttl=600,
)
# Assert
assert proof.cache_ttl == 600
def test_is_frozen_dataclass(self):
# Arrange
proof = CapabilityProof(
capability_name="streaming",
proven=True,
proof_timestamp=datetime(2026, 1, 1, 0, 0, 0),
)
# Act & Assert
with pytest.raises(Exception):
proof.capability_name = "other" # type: ignore[misc]
def test_proven_immutable(self):
# Arrange
proof = CapabilityProof(
capability_name="streaming",
proven=True,
proof_timestamp=datetime(2026, 1, 1, 0, 0, 0),
)
# Act & Assert
with pytest.raises(Exception):
proof.proven = False # type: ignore[misc]
# ─── CapabilityProver Protocol ──────────────────────────────────────────
@pytest.mark.unit
class TestCapabilityProverProtocol:
"""CapabilityProver 为 runtime_checkable Protocol。"""
def test_is_protocol_subclass(self):
# Arrange
# Act
# Assert
assert issubclass(CapabilityProver, Protocol)
def test_runtime_checkable_isinstance_works(self):
# Arrange - 使用最小实现实例替代 MagicMock兼容 Python 3.12
# runtime_checkable Protocol 的 isinstance 行为
# Act
# Assert
assert isinstance(_make_instance(CapabilityProver), CapabilityProver)
def test_protocol_has_prove_capability_method(self):
# Arrange
# Act
# Assert
assert hasattr(CapabilityProver, "proveCapability")
# ─── PluginHost Protocol ────────────────────────────────────────────────
@pytest.mark.unit
class TestPluginHostProtocol:
"""PluginHost 为 runtime_checkable Protocol。"""
def test_is_protocol_subclass(self):
# Arrange
# Act
# Assert
assert issubclass(PluginHost, Protocol)
def test_runtime_checkable_isinstance_works(self):
# Arrange - 使用最小实现实例替代 MagicMock兼容 Python 3.12
# runtime_checkable Protocol 的 isinstance 行为
# Act
# Assert
assert isinstance(_make_instance(PluginHost), PluginHost)
def test_protocol_has_port_getter_methods(self):
# Arrange - PluginHost 定义了若干 getXxxPort 方法
expected_methods = [
"getConfigPort",
"getLoggerPort",
"getPersistencePort",
"getCachePort",
"getTracerPort",
"getQueuePort",
"getConversationPort",
"getAgentRunPort",
"getIdentityResolverPort",
"getChannelAccountRepositoryPort",
"getChannelSessionRepositoryPort",
"getPairingRepositoryPort",
"getAuditLogRepositoryPort",
"getOutboxRepositoryPort",
"getUserIdentityRepositoryPort",
"getIdempotencyRepositoryPort",
"getPersistenceHealthPort",
]
# Act
# Assert
for name in expected_methods:
assert hasattr(PluginHost, name), f"PluginHost 缺少方法 {name}"
def test_protocol_has_register_methods(self):
# Arrange - PluginHost 定义了若干 registerXxx 方法
expected_methods = [
"registerAdapter",
"registerStageSlot",
"registerEventSubscription",
"registerConfigSource",
"registerCapabilityProver",
"registerMatchTier",
"registerMatcher",
]
# Act
# Assert
for name in expected_methods:
assert hasattr(PluginHost, name), f"PluginHost 缺少方法 {name}"
def test_protocol_no_declare_methods(self):
# Arrange - PluginHost 不再定义 declareXxx 方法
# (能力边界声明已统一到 ChannelManifest 静态字段)
removed_methods = [
"declareAccessiblePorts",
"declareInjectablePipelines",
"declareResourceQuota",
]
# Act
# Assert
for name in removed_methods:
assert not hasattr(PluginHost, name), f"PluginHost 不应再定义 {name}(已统一到 ChannelManifest"
def test_protocol_has_publish_event_method(self):
# Arrange
# Act
# Assert
assert hasattr(PluginHost, "publishEvent")
# ─── CHANNEL_ENTRY 类型别名 ─────────────────────────────────────────────
@pytest.mark.unit
class TestChannelEntryAlias:
"""CHANNEL_ENTRY 为 Callable 类型别名。"""
def test_is_callable_origin(self):
# Arrange
# Act
origin = get_origin(CHANNEL_ENTRY)
# Assert - CHANNEL_ENTRY 的 origin 为 collections.abc.Callable
assert origin is Callable
def test_args_contain_plugin_host_and_manifest(self):
# Arrange - CHANNEL_ENTRY = Callable[[PluginHost, ChannelManifest], PluginManifest]
# Act
args = get_args(CHANNEL_ENTRY)
# Assert - 参数列表含 PluginHost 与 ChannelManifest返回类型为 PluginManifest
assert PluginManifest in args
# args 结构为 ([PluginHost, ChannelManifest], PluginManifest),参数列表在 args[0]
param_args = args[0]
assert PluginHost in param_args
assert ChannelManifest in param_args
def test_callable_with_plugin_host_returns_manifest(self):
# Arrange - 构造一个符合 CHANNEL_ENTRY 签名的函数2 参数)
def entry(host: PluginHost, manifest: ChannelManifest) -> PluginManifest:
return PluginManifest(manifest=manifest)
# Act
result = entry(MagicMock(), _build_channel_manifest())
# Assert
assert isinstance(result, PluginManifest)