1187 lines
40 KiB
Python
1187 lines
40 KiB
Python
|
|
"""yuxi.channels.contract.plugin 模块单元测试。
|
|||
|
|
|
|||
|
|
覆盖插件协议包入口与各子模块:
|
|||
|
|
- ``__init__.py`` 的 ``__all__`` 导出完整性
|
|||
|
|
- ``manifest.py`` 的枚举与 dataclass(FailurePolicy / CredentialType / AcquireMethod /
|
|||
|
|
PluginDependency / ResourceQuota / CredentialStrategy / ChannelManifest /
|
|||
|
|
PluginManifest)
|
|||
|
|
- ``lifecycle.py`` 的枚举与 Protocol(LifecycleState / LifecycleHook /
|
|||
|
|
LifecycleHookHandler)
|
|||
|
|
- ``extension_point.py`` 的枚举、dataclass 与 Protocol(ConflictStrategy /
|
|||
|
|
FailureStrategy / StageSlot / EventSubscription / ConfigSource / Stage)
|
|||
|
|
- ``capability.py`` 的 dataclass 与 Protocol(CapabilityDeclaration /
|
|||
|
|
CapabilityProof / CapabilityProver)
|
|||
|
|
- ``entry.py`` 的 Protocol 与类型别名(PluginHost / CHANNEL_ENTRY)
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
from datetime import datetime
|
|||
|
|
from typing import Protocol, get_args, get_origin
|
|||
|
|
from collections.abc import Callable
|
|||
|
|
from enum import StrEnum
|
|||
|
|
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 _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 is 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 支持任意属性访问,runtime_checkable Protocol 的
|
|||
|
|
# isinstance 检查仅校验属性存在性
|
|||
|
|
# Act
|
|||
|
|
# Assert
|
|||
|
|
assert isinstance(MagicMock(), 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}"
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ─── 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 支持任意属性访问
|
|||
|
|
# Act
|
|||
|
|
# Assert
|
|||
|
|
assert isinstance(MagicMock(), 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
|
|||
|
|
# Assert
|
|||
|
|
for name in expected_attrs:
|
|||
|
|
assert 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 支持任意属性访问
|
|||
|
|
# Act
|
|||
|
|
# Assert
|
|||
|
|
assert isinstance(MagicMock(), 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 支持任意属性访问,runtime_checkable Protocol 的
|
|||
|
|
# isinstance 检查仅校验属性存在性
|
|||
|
|
# Act
|
|||
|
|
# Assert
|
|||
|
|
assert isinstance(MagicMock(), 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], PluginManifest]
|
|||
|
|
# Act
|
|||
|
|
args = get_args(CHANNEL_ENTRY)
|
|||
|
|
# Assert - 参数列表含 PluginHost,返回类型为 PluginManifest
|
|||
|
|
assert PluginManifest in args
|
|||
|
|
# args 结构为 ([PluginHost], PluginManifest),参数列表在 args[0]
|
|||
|
|
param_args = args[0]
|
|||
|
|
assert PluginHost in param_args
|
|||
|
|
|
|||
|
|
def test_callable_with_plugin_host_returns_manifest(self):
|
|||
|
|
# Arrange - 构造一个符合 CHANNEL_ENTRY 签名的函数
|
|||
|
|
def entry(host: PluginHost) -> PluginManifest:
|
|||
|
|
return PluginManifest(manifest=_build_channel_manifest())
|
|||
|
|
# Act
|
|||
|
|
result = entry(MagicMock())
|
|||
|
|
# Assert
|
|||
|
|
assert isinstance(result, PluginManifest)
|