ForcePilot/backend/test/unit/channels/contract/plugin/test_plugin.py
Kris b323922cdc style: 清理测试文件中的多余空行和导入顺序
本次提交清理了多个单元测试文件中的多余空行,调整了部分导入的顺序,同时修复了一处feishu错误翻译的导入顺序问题,移除了application/conftest.py中未使用的fake_report_repo fixture,优化代码格式提升可读性。
2026-07-06 20:52:20 +08:00

1286 lines
42 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.

"""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], 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)