bb83549cc0
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 22s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 36s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 23s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m0s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 57s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m4s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m59s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m8s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 50s
AI Code Review / AI Code Review (pull_request) Successful in 8m41s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 10m45s
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
覆盖 ModuleStatus枚举/QuotaRule/ModuleCapability/Module数据类、 Module.activate/disable状态转换、ModuleRegistry注册注销/ 自动激活/依赖检查/能力发现/状态过滤/全局单例
556 lines
18 KiB
Python
Executable File
556 lines
18 KiB
Python
Executable File
"""
|
||
Module Registry 模块注册中心单元测试
|
||
|
||
覆盖:
|
||
- ModuleStatus 枚举
|
||
- QuotaRule / ModuleCapability / Module 数据类
|
||
- Module.activate / disable 状态转换
|
||
- ModuleRegistry 注册/注销/查询/能力发现/依赖检查
|
||
"""
|
||
|
||
import pytest
|
||
|
||
from packages.infrastructure.module_registry import (
|
||
ModuleStatus,
|
||
QuotaRule,
|
||
ModuleCapability,
|
||
Module,
|
||
ModuleRegistry,
|
||
module_registry,
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# ModuleStatus
|
||
# ============================================================
|
||
|
||
|
||
class TestModuleStatus:
|
||
"""ModuleStatus 枚举"""
|
||
|
||
def test_enum_values(self):
|
||
assert ModuleStatus.REGISTERED.value == "registered"
|
||
assert ModuleStatus.ACTIVE.value == "active"
|
||
assert ModuleStatus.DISABLED.value == "disabled"
|
||
assert ModuleStatus.ERROR.value == "error"
|
||
|
||
def test_is_str_enum(self):
|
||
assert isinstance(ModuleStatus.ACTIVE, str)
|
||
assert ModuleStatus.ACTIVE == "active"
|
||
|
||
def test_has_four_states(self):
|
||
assert len(ModuleStatus) == 4
|
||
|
||
|
||
# ============================================================
|
||
# QuotaRule
|
||
# ============================================================
|
||
|
||
|
||
class TestQuotaRule:
|
||
"""QuotaRule 配额规则"""
|
||
|
||
def test_required_fields(self):
|
||
rule = QuotaRule(dimension="ai_credits", per_operation=1.0)
|
||
assert rule.dimension == "ai_credits"
|
||
assert rule.per_operation == 1.0
|
||
|
||
def test_default_description_empty(self):
|
||
rule = QuotaRule(dimension="storage_gb", per_operation=0.5)
|
||
assert rule.description == ""
|
||
|
||
def test_custom_description(self):
|
||
rule = QuotaRule(
|
||
dimension="credits",
|
||
per_operation=2.0,
|
||
description="每次生成消耗2积分",
|
||
)
|
||
assert rule.description == "每次生成消耗2积分"
|
||
|
||
def test_float_per_operation(self):
|
||
rule = QuotaRule(dimension="gb", per_operation=0.25)
|
||
assert rule.per_operation == 0.25
|
||
|
||
|
||
# ============================================================
|
||
# ModuleCapability
|
||
# ============================================================
|
||
|
||
|
||
class TestModuleCapability:
|
||
"""ModuleCapability 能力定义"""
|
||
|
||
def test_required_name(self):
|
||
cap = ModuleCapability(name="generate_voice")
|
||
assert cap.name == "generate_voice"
|
||
|
||
def test_defaults(self):
|
||
cap = ModuleCapability(name="test_cap")
|
||
assert cap.description == ""
|
||
assert cap.quota_rules == []
|
||
assert cap.metadata == {}
|
||
|
||
def test_with_quota_rules(self):
|
||
rules = [QuotaRule(dimension="credits", per_operation=1.0)]
|
||
cap = ModuleCapability(
|
||
name="generate",
|
||
description="生成功能",
|
||
quota_rules=rules,
|
||
)
|
||
assert cap.description == "生成功能"
|
||
assert len(cap.quota_rules) == 1
|
||
assert cap.quota_rules[0].dimension == "credits"
|
||
|
||
def test_with_metadata(self):
|
||
cap = ModuleCapability(
|
||
name="export",
|
||
metadata={"format": "mp4", "max_resolution": "1080p"},
|
||
)
|
||
assert cap.metadata["format"] == "mp4"
|
||
assert cap.metadata["max_resolution"] == "1080p"
|
||
|
||
|
||
# ============================================================
|
||
# Module
|
||
# ============================================================
|
||
|
||
|
||
class TestModuleDefaults:
|
||
"""Module 数据类默认值"""
|
||
|
||
def test_required_name(self):
|
||
mod = Module(name="ai_voice")
|
||
assert mod.name == "ai_voice"
|
||
|
||
def test_default_version(self):
|
||
mod = Module(name="test")
|
||
assert mod.version == "1.0.0"
|
||
|
||
def test_default_description(self):
|
||
mod = Module(name="test")
|
||
assert mod.description == ""
|
||
|
||
def test_default_capabilities_empty(self):
|
||
mod = Module(name="test")
|
||
assert mod.capabilities == []
|
||
|
||
def test_default_dependencies_empty(self):
|
||
mod = Module(name="test")
|
||
assert mod.dependencies == []
|
||
|
||
def test_default_status_registered(self):
|
||
mod = Module(name="test")
|
||
assert mod.status == ModuleStatus.REGISTERED
|
||
|
||
def test_default_config_empty(self):
|
||
mod = Module(name="test")
|
||
assert mod.config == {}
|
||
|
||
def test_full_module(self):
|
||
cap = ModuleCapability(name="do_something")
|
||
mod = Module(
|
||
name="full_module",
|
||
version="2.0.0",
|
||
description="完整模块",
|
||
capabilities=[cap],
|
||
dependencies=["dep1", "dep2"],
|
||
status=ModuleStatus.ACTIVE,
|
||
config={"key": "value"},
|
||
)
|
||
assert mod.version == "2.0.0"
|
||
assert mod.description == "完整模块"
|
||
assert len(mod.capabilities) == 1
|
||
assert mod.dependencies == ["dep1", "dep2"]
|
||
assert mod.status == ModuleStatus.ACTIVE
|
||
assert mod.config["key"] == "value"
|
||
|
||
|
||
class TestModuleActivate:
|
||
"""Module.activate 状态转换"""
|
||
|
||
def test_activate_from_registered(self):
|
||
mod = Module(name="test")
|
||
mod.activate()
|
||
assert mod.status == ModuleStatus.ACTIVE
|
||
|
||
def test_activate_from_disabled(self):
|
||
mod = Module(name="test", status=ModuleStatus.DISABLED)
|
||
mod.activate()
|
||
assert mod.status == ModuleStatus.ACTIVE
|
||
|
||
def test_activate_from_error_stays_error(self):
|
||
mod = Module(name="test", status=ModuleStatus.ERROR)
|
||
mod.activate()
|
||
# error 状态不可激活
|
||
assert mod.status == ModuleStatus.ERROR
|
||
|
||
def test_activate_already_active(self):
|
||
mod = Module(name="test", status=ModuleStatus.ACTIVE)
|
||
mod.activate()
|
||
assert mod.status == ModuleStatus.ACTIVE
|
||
|
||
|
||
class TestModuleDisable:
|
||
"""Module.disable 状态转换"""
|
||
|
||
def test_disable_from_registered(self):
|
||
mod = Module(name="test")
|
||
mod.disable()
|
||
assert mod.status == ModuleStatus.DISABLED
|
||
|
||
def test_disable_from_active(self):
|
||
mod = Module(name="test", status=ModuleStatus.ACTIVE)
|
||
mod.disable()
|
||
assert mod.status == ModuleStatus.DISABLED
|
||
|
||
def test_disable_from_error(self):
|
||
mod = Module(name="test", status=ModuleStatus.ERROR)
|
||
mod.disable()
|
||
assert mod.status == ModuleStatus.DISABLED
|
||
|
||
def test_disable_already_disabled(self):
|
||
mod = Module(name="test", status=ModuleStatus.DISABLED)
|
||
mod.disable()
|
||
assert mod.status == ModuleStatus.DISABLED
|
||
|
||
|
||
# ============================================================
|
||
# ModuleRegistry - 基础操作
|
||
# ============================================================
|
||
|
||
|
||
class TestModuleRegistryBasic:
|
||
"""ModuleRegistry 基础操作"""
|
||
|
||
def test_empty_registry(self):
|
||
registry = ModuleRegistry()
|
||
assert registry.list_modules() == []
|
||
assert registry.get_active_capabilities() == {}
|
||
|
||
def test_register_single_module(self):
|
||
registry = ModuleRegistry()
|
||
mod = Module(name="test_mod")
|
||
registry.register(mod)
|
||
assert registry.get("test_mod") is mod
|
||
|
||
def test_register_duplicate_raises(self):
|
||
registry = ModuleRegistry()
|
||
registry.register(Module(name="test_mod"))
|
||
with pytest.raises(ValueError, match="already registered"):
|
||
registry.register(Module(name="test_mod"))
|
||
|
||
def test_get_nonexistent_returns_none(self):
|
||
registry = ModuleRegistry()
|
||
assert registry.get("no_such_module") is None
|
||
|
||
def test_unregister_success(self):
|
||
registry = ModuleRegistry()
|
||
registry.register(Module(name="test_mod"))
|
||
registry.unregister("test_mod")
|
||
assert registry.get("test_mod") is None
|
||
|
||
def test_unregister_nonexistent_raises(self):
|
||
registry = ModuleRegistry()
|
||
with pytest.raises(KeyError, match="not found"):
|
||
registry.unregister("no_such_module")
|
||
|
||
def test_unregister_with_dependents_raises(self):
|
||
registry = ModuleRegistry()
|
||
registry.register(Module(name="base_module"))
|
||
registry.register(Module(name="dependent_module", dependencies=["base_module"]))
|
||
with pytest.raises(ValueError, match="depended on by"):
|
||
registry.unregister("base_module")
|
||
|
||
def test_clear(self):
|
||
registry = ModuleRegistry()
|
||
registry.register(Module(name="mod1"))
|
||
registry.register(Module(name="mod2"))
|
||
registry.clear()
|
||
assert registry.list_modules() == []
|
||
|
||
|
||
# ============================================================
|
||
# ModuleRegistry - 自动激活 & 依赖
|
||
# ============================================================
|
||
|
||
|
||
class TestModuleRegistryAutoActivate:
|
||
"""注册时自动激活逻辑"""
|
||
|
||
def test_no_deps_auto_activates(self):
|
||
registry = ModuleRegistry()
|
||
mod = Module(name="standalone")
|
||
registry.register(mod)
|
||
assert mod.status == ModuleStatus.ACTIVE
|
||
|
||
def test_with_deps_all_satisfied_auto_activates(self):
|
||
registry = ModuleRegistry()
|
||
registry.register(Module(name="base")) # 无依赖,自动激活
|
||
dep_mod = Module(name="dependent", dependencies=["base"])
|
||
registry.register(dep_mod)
|
||
assert dep_mod.status == ModuleStatus.ACTIVE
|
||
|
||
def test_with_deps_not_satisfied_stays_registered(self):
|
||
registry = ModuleRegistry()
|
||
mod = Module(name="dependent", dependencies=["missing_dep"])
|
||
registry.register(mod)
|
||
# 依赖不满足,保持 REGISTERED
|
||
assert mod.status == ModuleStatus.REGISTERED
|
||
|
||
def test_later_dep_registered_manual_activate(self):
|
||
"""先注册依赖模块,再注册被依赖模块时不自动激活前者
|
||
(需要手动或在注册完所有模块后调用 check_dependencies + activate)"""
|
||
registry = ModuleRegistry()
|
||
# 先注册依赖方(依赖未满足,不激活)
|
||
dependent = Module(name="dependent", dependencies=["base"])
|
||
registry.register(dependent)
|
||
assert dependent.status == ModuleStatus.REGISTERED
|
||
|
||
# 再注册被依赖方
|
||
base = Module(name="base")
|
||
registry.register(base)
|
||
assert base.status == ModuleStatus.ACTIVE
|
||
|
||
# 依赖方仍然是 REGISTERED(不会自动激活)
|
||
assert dependent.status == ModuleStatus.REGISTERED
|
||
|
||
|
||
class TestModuleRegistryCheckDependencies:
|
||
"""check_dependencies 依赖检查"""
|
||
|
||
def test_module_not_found_returns_false(self):
|
||
registry = ModuleRegistry()
|
||
assert registry.check_dependencies("nonexistent") is False
|
||
|
||
def test_no_deps_returns_true(self):
|
||
registry = ModuleRegistry()
|
||
registry.register(Module(name="standalone"))
|
||
assert registry.check_dependencies("standalone") is True
|
||
|
||
def test_all_deps_active_returns_true(self):
|
||
registry = ModuleRegistry()
|
||
registry.register(Module(name="dep1"))
|
||
registry.register(Module(name="dep2"))
|
||
registry.register(Module(name="main", dependencies=["dep1", "dep2"]))
|
||
# main 在注册时因依赖满足已自动激活
|
||
assert registry.check_dependencies("main") is True
|
||
|
||
def test_dep_not_registered_returns_false(self):
|
||
registry = ModuleRegistry()
|
||
mod = Module(name="main", dependencies=["missing"])
|
||
registry.register(mod)
|
||
assert registry.check_dependencies("main") is False
|
||
|
||
def test_dep_registered_but_not_active_returns_false(self):
|
||
registry = ModuleRegistry()
|
||
dep = Module(name="dep", status=ModuleStatus.DISABLED)
|
||
registry.register(dep)
|
||
# 手动设为 disabled(因为 register 时无依赖会自动激活)
|
||
dep.disable()
|
||
main = Module(name="main", dependencies=["dep"])
|
||
registry.register(main)
|
||
# 依赖未激活
|
||
assert registry.check_dependencies("main") is False
|
||
|
||
|
||
# ============================================================
|
||
# ModuleRegistry - list_modules & 状态过滤
|
||
# ============================================================
|
||
|
||
|
||
class TestModuleRegistryList:
|
||
"""list_modules 列表与过滤"""
|
||
|
||
def test_list_all(self):
|
||
registry = ModuleRegistry()
|
||
registry.register(Module(name="mod1"))
|
||
registry.register(Module(name="mod2"))
|
||
modules = registry.list_modules()
|
||
assert len(modules) == 2
|
||
names = {m.name for m in modules}
|
||
assert names == {"mod1", "mod2"}
|
||
|
||
def test_filter_by_active(self):
|
||
registry = ModuleRegistry()
|
||
registry.register(Module(name="active_mod")) # 自动激活
|
||
disabled = Module(name="disabled_mod")
|
||
registry.register(disabled)
|
||
disabled.disable()
|
||
|
||
active = registry.list_modules(status=ModuleStatus.ACTIVE)
|
||
assert len(active) == 1
|
||
assert active[0].name == "active_mod"
|
||
|
||
def test_filter_by_disabled(self):
|
||
registry = ModuleRegistry()
|
||
registry.register(Module(name="active_mod"))
|
||
disabled = Module(name="disabled_mod")
|
||
registry.register(disabled)
|
||
disabled.disable()
|
||
|
||
disabled_list = registry.list_modules(status=ModuleStatus.DISABLED)
|
||
assert len(disabled_list) == 1
|
||
assert disabled_list[0].name == "disabled_mod"
|
||
|
||
def test_filter_registered(self):
|
||
registry = ModuleRegistry()
|
||
# 有依赖未满足的模块保持 REGISTERED
|
||
mod = Module(name="waiting_mod", dependencies=["missing"])
|
||
registry.register(mod)
|
||
|
||
registered = registry.list_modules(status=ModuleStatus.REGISTERED)
|
||
assert len(registered) == 1
|
||
assert registered[0].name == "waiting_mod"
|
||
|
||
|
||
# ============================================================
|
||
# ModuleRegistry - 能力发现
|
||
# ============================================================
|
||
|
||
|
||
class TestModuleRegistryCapabilities:
|
||
"""能力发现:has_capability / get_capability / get_quota_rules"""
|
||
|
||
def test_has_capability_true(self):
|
||
registry = ModuleRegistry()
|
||
registry.register(Module(
|
||
name="voice_module",
|
||
capabilities=[ModuleCapability(name="generate_voice")],
|
||
))
|
||
assert registry.has_capability("generate_voice") is True
|
||
|
||
def test_has_capability_false(self):
|
||
registry = ModuleRegistry()
|
||
registry.register(Module(
|
||
name="voice_module",
|
||
capabilities=[ModuleCapability(name="generate_voice")],
|
||
))
|
||
assert registry.has_capability("generate_video") is False
|
||
|
||
def test_has_capability_inactive_module_not_counted(self):
|
||
registry = ModuleRegistry()
|
||
mod = Module(
|
||
name="inactive_mod",
|
||
capabilities=[ModuleCapability(name="secret_cap")],
|
||
)
|
||
registry.register(mod)
|
||
mod.disable()
|
||
assert registry.has_capability("secret_cap") is False
|
||
|
||
def test_get_capability_returns_first_match(self):
|
||
registry = ModuleRegistry()
|
||
cap1 = ModuleCapability(name="export", description="导出1")
|
||
cap2 = ModuleCapability(name="export", description="导出2")
|
||
registry.register(Module(name="mod1", capabilities=[cap1]))
|
||
registry.register(Module(name="mod2", capabilities=[cap2]))
|
||
|
||
result = registry.get_capability("export")
|
||
assert result is not None
|
||
assert result.name == "export"
|
||
# 返回第一个匹配的(mod1)
|
||
assert result.description == "导出1"
|
||
|
||
def test_get_capability_nonexistent_returns_none(self):
|
||
registry = ModuleRegistry()
|
||
assert registry.get_capability("no_such_cap") is None
|
||
|
||
def test_get_quota_rules(self):
|
||
rules = [
|
||
QuotaRule(dimension="credits", per_operation=1.0),
|
||
QuotaRule(dimension="storage", per_operation=0.5),
|
||
]
|
||
registry = ModuleRegistry()
|
||
registry.register(Module(
|
||
name="voice_mod",
|
||
capabilities=[ModuleCapability(name="gen", quota_rules=rules)],
|
||
))
|
||
result = registry.get_quota_rules("gen")
|
||
assert len(result) == 2
|
||
assert result[0].dimension == "credits"
|
||
assert result[1].dimension == "storage"
|
||
|
||
def test_get_quota_rules_nonexistent_returns_empty(self):
|
||
registry = ModuleRegistry()
|
||
assert registry.get_quota_rules("no_cap") == []
|
||
|
||
|
||
# ============================================================
|
||
# ModuleRegistry - get_active_capabilities
|
||
# ============================================================
|
||
|
||
|
||
class TestModuleRegistryActiveCapabilities:
|
||
"""get_active_capabilities 已激活能力汇总"""
|
||
|
||
def test_empty_registry(self):
|
||
registry = ModuleRegistry()
|
||
assert registry.get_active_capabilities() == {}
|
||
|
||
def test_single_module_with_caps(self):
|
||
registry = ModuleRegistry()
|
||
registry.register(Module(
|
||
name="voice_mod",
|
||
capabilities=[
|
||
ModuleCapability(name="generate_voice"),
|
||
ModuleCapability(name="clone_voice"),
|
||
],
|
||
))
|
||
result = registry.get_active_capabilities()
|
||
assert "voice_mod" in result
|
||
assert set(result["voice_mod"]) == {"generate_voice", "clone_voice"}
|
||
|
||
def test_skips_inactive_modules(self):
|
||
registry = ModuleRegistry()
|
||
registry.register(Module(
|
||
name="active_mod",
|
||
capabilities=[ModuleCapability(name="active_cap")],
|
||
))
|
||
inactive = Module(
|
||
name="inactive_mod",
|
||
capabilities=[ModuleCapability(name="inactive_cap")],
|
||
)
|
||
registry.register(inactive)
|
||
inactive.disable()
|
||
|
||
result = registry.get_active_capabilities()
|
||
assert "active_mod" in result
|
||
assert "inactive_mod" not in result
|
||
|
||
def test_skips_modules_without_caps(self):
|
||
registry = ModuleRegistry()
|
||
registry.register(Module(name="no_cap_mod"))
|
||
result = registry.get_active_capabilities()
|
||
assert "no_cap_mod" not in result
|
||
|
||
def test_multiple_modules(self):
|
||
registry = ModuleRegistry()
|
||
registry.register(Module(
|
||
name="mod1",
|
||
capabilities=[ModuleCapability(name="cap_a")],
|
||
))
|
||
registry.register(Module(
|
||
name="mod2",
|
||
capabilities=[ModuleCapability(name="cap_b"), ModuleCapability(name="cap_c")],
|
||
))
|
||
result = registry.get_active_capabilities()
|
||
assert len(result) == 2
|
||
assert result["mod1"] == ["cap_a"]
|
||
assert set(result["mod2"]) == {"cap_b", "cap_c"}
|
||
|
||
|
||
# ============================================================
|
||
# 全局单例
|
||
# ============================================================
|
||
|
||
|
||
class TestGlobalSingleton:
|
||
"""全局 module_registry 单例"""
|
||
|
||
def test_singleton_exists(self):
|
||
assert module_registry is not None
|
||
assert isinstance(module_registry, ModuleRegistry)
|
||
|
||
def test_singleton_is_same_instance(self):
|
||
from packages.infrastructure.module_registry import module_registry as mr2
|
||
assert module_registry is mr2
|