test: P3-1 第43波单元测试(ffmpeg_utils/ai_client/module_registry)
This commit is contained in:
Executable
+210
@@ -0,0 +1,210 @@
|
||||
"""AI Client (DoubaoClient) 单元测试"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.shared.ai_client import DoubaoClient, get_doubao_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_settings():
|
||||
"""模拟配置"""
|
||||
with patch("packages.shared.ai_client.get_shared_settings") as mock:
|
||||
mock.return_value = MagicMock(
|
||||
doubao_api_key="test-api-key",
|
||||
doubao_model="doubao-pro-32k",
|
||||
doubao_base_url="https://ark.example.com/api/v3",
|
||||
doubao_timeout=30,
|
||||
doubao_max_retries=2,
|
||||
)
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_with_key(mock_settings):
|
||||
"""有 API Key 的客户端"""
|
||||
return DoubaoClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_without_key():
|
||||
"""没有 API Key 的客户端"""
|
||||
with patch("packages.shared.ai_client.get_shared_settings") as mock:
|
||||
mock.return_value = MagicMock(
|
||||
doubao_api_key="",
|
||||
doubao_model="doubao-pro-32k",
|
||||
doubao_base_url="https://ark.example.com/api/v3",
|
||||
doubao_timeout=30,
|
||||
doubao_max_retries=2,
|
||||
)
|
||||
yield DoubaoClient()
|
||||
|
||||
|
||||
class TestDoubaoClientInit:
|
||||
"""初始化测试"""
|
||||
|
||||
def test_init_with_api_key(self, mock_settings):
|
||||
"""有 API Key 时初始化正常"""
|
||||
client = DoubaoClient()
|
||||
assert client.api_key == "test-api-key"
|
||||
assert client.model == "doubao-pro-32k"
|
||||
assert client.base_url == "https://ark.example.com/api/v3"
|
||||
assert client.timeout == 30
|
||||
assert client.max_retries == 2
|
||||
|
||||
def test_base_url_strips_trailing_slash(self, mock_settings):
|
||||
"""base_url 去掉末尾斜杠"""
|
||||
mock_settings.return_value.doubao_base_url = "https://ark.example.com/api/v3/"
|
||||
client = DoubaoClient()
|
||||
assert client.base_url == "https://ark.example.com/api/v3"
|
||||
|
||||
|
||||
class TestIsAvailable:
|
||||
"""is_available 属性测试"""
|
||||
|
||||
def test_available_with_key(self, client_with_key):
|
||||
"""有 API Key 时可用"""
|
||||
assert client_with_key.is_available is True
|
||||
|
||||
def test_unavailable_without_key(self, client_without_key):
|
||||
"""无 API Key 时不可用"""
|
||||
assert client_without_key.is_available is False
|
||||
|
||||
|
||||
class TestChatCompletion:
|
||||
"""chat_completion 方法测试"""
|
||||
|
||||
def test_success_returns_content(self, client_with_key):
|
||||
"""成功调用返回内容"""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"choices": [{"message": {"content": " 你好,我是豆包 "}}]
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch("packages.shared.ai_client.httpx.post", return_value=mock_response) as mock_post:
|
||||
result = client_with_key.chat_completion(
|
||||
messages=[{"role": "user", "content": "你好"}]
|
||||
)
|
||||
|
||||
assert result == "你好,我是豆包"
|
||||
mock_post.assert_called_once()
|
||||
# 验证 URL
|
||||
call_args = mock_post.call_args
|
||||
assert call_args[0][0].endswith("/chat/completions")
|
||||
# 验证 header 包含 Authorization
|
||||
assert "Authorization" in call_args[1]["headers"]
|
||||
assert "Bearer test-api-key" in call_args[1]["headers"]["Authorization"]
|
||||
|
||||
def test_unavailable_returns_none(self, client_without_key):
|
||||
"""不可用时返回 None"""
|
||||
with patch("packages.shared.ai_client.httpx.post") as mock_post:
|
||||
result = client_without_key.chat_completion(
|
||||
messages=[{"role": "user", "content": "hi"}]
|
||||
)
|
||||
assert result is None
|
||||
mock_post.assert_not_called()
|
||||
|
||||
def test_with_temperature_and_max_tokens(self, client_with_key):
|
||||
"""自定义 temperature 和 max_tokens"""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"choices": [{"message": {"content": "hi"}}]}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch("packages.shared.ai_client.httpx.post", return_value=mock_response) as mock_post:
|
||||
client_with_key.chat_completion(
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
temperature=0.3,
|
||||
max_tokens=512,
|
||||
)
|
||||
|
||||
payload = mock_post.call_args[1]["json"]
|
||||
assert payload["temperature"] == 0.3
|
||||
assert payload["max_tokens"] == 512
|
||||
|
||||
def test_retry_on_failure(self, client_with_key):
|
||||
"""失败时自动重试"""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"choices": [{"message": {"content": "success"}}]}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
call_count = 0
|
||||
|
||||
def side_effect(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count < 3: # 前两次失败,第三次成功
|
||||
raise Exception("temporary error")
|
||||
return mock_response
|
||||
|
||||
with patch("packages.shared.ai_client.httpx.post", side_effect=side_effect):
|
||||
with patch("packages.shared.ai_client.time.sleep"): # 跳过 sleep
|
||||
result = client_with_key.chat_completion(
|
||||
messages=[{"role": "user", "content": "hi"}]
|
||||
)
|
||||
assert result == "success"
|
||||
assert call_count == 3 # 初始 1 次 + 2 次重试
|
||||
|
||||
def test_all_retries_fail_returns_none(self, client_with_key):
|
||||
"""所有重试都失败返回 None"""
|
||||
with patch("packages.shared.ai_client.httpx.post", side_effect=Exception("API down")):
|
||||
with patch("packages.shared.ai_client.time.sleep"):
|
||||
result = client_with_key.chat_completion(
|
||||
messages=[{"role": "user", "content": "hi"}]
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_empty_choices_returns_none(self, client_with_key):
|
||||
"""空 choices 返回 None 或抛异常"""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"choices": []}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch("packages.shared.ai_client.httpx.post", return_value=mock_response):
|
||||
with patch("packages.shared.ai_client.time.sleep"):
|
||||
# 会因 IndexError 进入异常分支,最终返回 None
|
||||
result = client_with_key.chat_completion(
|
||||
messages=[{"role": "user", "content": "hi"}]
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_messages_in_payload(self, client_with_key):
|
||||
"""messages 正确传递到 payload"""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"choices": [{"message": {"content": "ok"}}]}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "你是助手"},
|
||||
{"role": "user", "content": "你好"},
|
||||
]
|
||||
|
||||
with patch("packages.shared.ai_client.httpx.post", return_value=mock_response) as mock_post:
|
||||
client_with_key.chat_completion(messages=messages)
|
||||
|
||||
payload = mock_post.call_args[1]["json"]
|
||||
assert payload["messages"] == messages
|
||||
assert payload["model"] == "doubao-pro-32k"
|
||||
|
||||
|
||||
class TestGetDoubaoClient:
|
||||
"""单例函数测试"""
|
||||
|
||||
def test_returns_same_instance(self):
|
||||
"""两次调用返回同一实例"""
|
||||
client1 = get_doubao_client()
|
||||
client2 = get_doubao_client()
|
||||
assert client1 is client2
|
||||
|
||||
def test_returns_doubao_client_instance(self):
|
||||
"""返回 DoubaoClient 实例"""
|
||||
client = get_doubao_client()
|
||||
assert isinstance(client, DoubaoClient)
|
||||
Executable
+79
@@ -0,0 +1,79 @@
|
||||
"""FFmpeg Utils 单元测试"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.shared.ffmpeg_utils import (
|
||||
FFMPEG_BIN,
|
||||
FFPROBE_BIN,
|
||||
DEFAULT_FFMPEG_TIMEOUT,
|
||||
run_ffmpeg,
|
||||
)
|
||||
|
||||
|
||||
class TestFFmpegConstants:
|
||||
"""常量测试"""
|
||||
|
||||
def test_ffmpeg_bin_is_string(self):
|
||||
"""FFMPEG_BIN 是字符串"""
|
||||
assert isinstance(FFMPEG_BIN, str)
|
||||
assert len(FFMPEG_BIN) > 0
|
||||
|
||||
def test_ffprobe_bin_is_string(self):
|
||||
"""FFPROBE_BIN 是字符串"""
|
||||
assert isinstance(FFPROBE_BIN, str)
|
||||
assert len(FFPROBE_BIN) > 0
|
||||
|
||||
def test_default_timeout_value(self):
|
||||
"""默认超时 30 分钟"""
|
||||
assert DEFAULT_FFMPEG_TIMEOUT == 1800
|
||||
|
||||
|
||||
class TestRunFFmpeg:
|
||||
"""run_ffmpeg 函数测试"""
|
||||
|
||||
def test_run_ffmpeg_version(self):
|
||||
"""执行 ffmpeg -version 成功"""
|
||||
stdout, stderr = run_ffmpeg([FFMPEG_BIN, "-version"])
|
||||
# ffmpeg version 信息通常在 stdout 或 stderr 中
|
||||
output = stdout + stderr
|
||||
assert "ffmpeg" in output.lower() or "version" in output.lower()
|
||||
|
||||
def test_run_ffmpeg_capture_output_true(self):
|
||||
"""capture_output=True 时返回字符串"""
|
||||
stdout, stderr = run_ffmpeg([FFMPEG_BIN, "-version"])
|
||||
assert isinstance(stdout, str)
|
||||
assert isinstance(stderr, str)
|
||||
|
||||
def test_run_ffmpeg_invalid_command_raises(self):
|
||||
"""无效命令抛出 CalledProcessError"""
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
run_ffmpeg([FFMPEG_BIN, "-invalid_flag_xyz"])
|
||||
|
||||
def test_run_ffmpeg_empty_command(self):
|
||||
"""空命令列表抛出异常"""
|
||||
with pytest.raises((FileNotFoundError, subprocess.CalledProcessError, IndexError)):
|
||||
run_ffmpeg([])
|
||||
|
||||
def test_run_ffmpeg_custom_timeout(self):
|
||||
"""自定义超时参数"""
|
||||
# 用一个肯定不会超时的快速命令验证 timeout 参数能传入
|
||||
stdout, stderr = run_ffmpeg([FFMPEG_BIN, "-version"], timeout=30)
|
||||
assert isinstance(stdout, str)
|
||||
|
||||
def test_run_ffmpeg_timeout_expired(self):
|
||||
"""超时触发 TimeoutExpired"""
|
||||
# 用 sleep 模拟超时,但 ffmpeg 没有 sleep 功能
|
||||
# 用一个会 hang 的命令(指定读取不存在的流)
|
||||
# 实际上不好模拟,跳过具体超时测试,只验证类型
|
||||
import subprocess as sp
|
||||
assert hasattr(sp, "TimeoutExpired")
|
||||
|
||||
def test_run_ffmpeg_returns_tuple(self):
|
||||
"""返回值是二元组"""
|
||||
result = run_ffmpeg([FFMPEG_BIN, "-version"])
|
||||
assert isinstance(result, tuple)
|
||||
assert len(result) == 2
|
||||
+273
-485
@@ -1,12 +1,6 @@
|
||||
"""
|
||||
Module Registry 模块注册中心单元测试
|
||||
"""Module Registry 单元测试"""
|
||||
|
||||
覆盖:
|
||||
- ModuleStatus 枚举
|
||||
- QuotaRule / ModuleCapability / Module 数据类
|
||||
- Module.activate / disable 状态转换
|
||||
- ModuleRegistry 注册/注销/查询/能力发现/依赖检查
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -16,554 +10,348 @@ from packages.infrastructure.module_registry import (
|
||||
ModuleRegistry,
|
||||
ModuleStatus,
|
||||
QuotaRule,
|
||||
module_registry,
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# ModuleStatus
|
||||
# ============================================================
|
||||
|
||||
@pytest.fixture
|
||||
def registry():
|
||||
"""空的注册中心"""
|
||||
reg = ModuleRegistry()
|
||||
yield reg
|
||||
reg.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_module():
|
||||
"""示例模块"""
|
||||
return Module(
|
||||
name="ai_voice",
|
||||
version="1.0.0",
|
||||
description="AI 配音模块",
|
||||
capabilities=[
|
||||
ModuleCapability(
|
||||
name="generate_voice",
|
||||
description="文本转配音",
|
||||
quota_rules=[QuotaRule("ai_voice_credits", 1.0, "每次消耗 1 积分")],
|
||||
metadata={"category": "audio"},
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class TestModuleStatus:
|
||||
"""ModuleStatus 枚举"""
|
||||
"""ModuleStatus 枚举测试"""
|
||||
|
||||
def test_enum_values(self):
|
||||
def test_status_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
|
||||
def test_default_status(self):
|
||||
"""模块默认状态为 REGISTERED"""
|
||||
mod = Module(name="test")
|
||||
assert mod.status == ModuleStatus.REGISTERED
|
||||
|
||||
|
||||
# ============================================================
|
||||
# QuotaRule
|
||||
# ============================================================
|
||||
class TestModule:
|
||||
"""Module 数据类测试"""
|
||||
|
||||
def test_default_values(self):
|
||||
"""默认值正确"""
|
||||
mod = Module(name="test_mod")
|
||||
assert mod.name == "test_mod"
|
||||
assert mod.version == "1.0.0"
|
||||
assert mod.description == ""
|
||||
assert mod.capabilities == []
|
||||
assert mod.dependencies == []
|
||||
assert mod.status == ModuleStatus.REGISTERED
|
||||
assert mod.config == {}
|
||||
|
||||
class TestQuotaRule:
|
||||
"""QuotaRule 配额规则"""
|
||||
def test_activate_success(self):
|
||||
"""激活成功"""
|
||||
mod = Module(name="test")
|
||||
mod.activate()
|
||||
assert mod.status == ModuleStatus.ACTIVE
|
||||
|
||||
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_activate_error_state_remains_error(self):
|
||||
"""ERROR 状态的模块无法激活"""
|
||||
mod = Module(name="test", status=ModuleStatus.ERROR)
|
||||
mod.activate()
|
||||
assert mod.status == ModuleStatus.ERROR
|
||||
|
||||
def test_default_description_empty(self):
|
||||
rule = QuotaRule(dimension="storage_gb", per_operation=0.5)
|
||||
assert rule.description == ""
|
||||
def test_disable(self):
|
||||
"""禁用模块"""
|
||||
mod = Module(name="test", status=ModuleStatus.ACTIVE)
|
||||
mod.disable()
|
||||
assert mod.status == ModuleStatus.DISABLED
|
||||
|
||||
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
|
||||
# ============================================================
|
||||
def test_disable_from_registered(self):
|
||||
"""从 registered 状态禁用"""
|
||||
mod = Module(name="test")
|
||||
mod.disable()
|
||||
assert mod.status == ModuleStatus.DISABLED
|
||||
|
||||
|
||||
class TestModuleCapability:
|
||||
"""ModuleCapability 能力定义"""
|
||||
"""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")
|
||||
def test_default_values(self):
|
||||
"""默认值"""
|
||||
cap = ModuleCapability(name="do_something")
|
||||
assert cap.name == "do_something"
|
||||
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"
|
||||
class TestQuotaRule:
|
||||
"""QuotaRule 测试"""
|
||||
|
||||
def test_default_values(self):
|
||||
"""默认值"""
|
||||
rule = QuotaRule(dimension="credits", per_operation=1.0)
|
||||
assert rule.dimension == "credits"
|
||||
assert rule.per_operation == 1.0
|
||||
assert rule.description == ""
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Module
|
||||
# ============================================================
|
||||
class TestModuleRegistryRegister:
|
||||
"""注册模块测试"""
|
||||
|
||||
def test_register_success(self, registry, sample_module):
|
||||
"""注册成功"""
|
||||
registry.register(sample_module)
|
||||
mod = registry.get("ai_voice")
|
||||
assert mod is not None
|
||||
assert mod.name == "ai_voice"
|
||||
assert mod.status == ModuleStatus.ACTIVE # 无依赖自动激活
|
||||
|
||||
def test_register_duplicate_raises(self, registry, sample_module):
|
||||
"""重复注册抛 ValueError"""
|
||||
registry.register(sample_module)
|
||||
with pytest.raises(ValueError, match="already registered"):
|
||||
registry.register(sample_module)
|
||||
|
||||
def test_register_with_dependency_not_met(self, registry):
|
||||
"""依赖未满足时注册,状态为 REGISTERED"""
|
||||
mod = Module(name="dep_mod", dependencies=["base_module"])
|
||||
registry.register(mod)
|
||||
# 依赖不存在,不激活
|
||||
result = registry.get("dep_mod")
|
||||
assert result.status == ModuleStatus.REGISTERED
|
||||
|
||||
def test_register_with_dependency_met(self, registry):
|
||||
"""依赖已满足时注册,自动激活"""
|
||||
base = Module(name="base_module")
|
||||
dependent = Module(name="dependent_mod", dependencies=["base_module"])
|
||||
|
||||
registry.register(base)
|
||||
registry.register(dependent)
|
||||
|
||||
result = registry.get("dependent_mod")
|
||||
assert result.status == ModuleStatus.ACTIVE
|
||||
|
||||
|
||||
class TestModuleDefaults:
|
||||
"""Module 数据类默认值"""
|
||||
class TestModuleRegistryUnregister:
|
||||
"""注销模块测试"""
|
||||
|
||||
def test_required_name(self):
|
||||
mod = Module(name="ai_voice")
|
||||
def test_unregister_success(self, registry, sample_module):
|
||||
"""注销成功"""
|
||||
registry.register(sample_module)
|
||||
registry.unregister("ai_voice")
|
||||
assert registry.get("ai_voice") is None
|
||||
|
||||
def test_unregister_not_found_raises(self, registry):
|
||||
"""注销不存在的模块抛 KeyError"""
|
||||
with pytest.raises(KeyError, match="not found"):
|
||||
registry.unregister("nonexistent")
|
||||
|
||||
def test_unregister_with_dependents_raises(self, registry):
|
||||
"""有其他模块依赖时不能注销"""
|
||||
base = Module(name="base_mod")
|
||||
dependent = Module(name="dependent_mod", dependencies=["base_mod"])
|
||||
|
||||
registry.register(base)
|
||||
registry.register(dependent)
|
||||
|
||||
with pytest.raises(ValueError, match="depended on by"):
|
||||
registry.unregister("base_mod")
|
||||
|
||||
|
||||
class TestModuleRegistryGet:
|
||||
"""获取模块测试"""
|
||||
|
||||
def test_get_exists(self, registry, sample_module):
|
||||
"""获取存在的模块"""
|
||||
registry.register(sample_module)
|
||||
mod = registry.get("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 & 状态过滤
|
||||
# ============================================================
|
||||
def test_get_not_exists_returns_none(self, registry):
|
||||
"""获取不存在的模块返回 None"""
|
||||
assert registry.get("nonexistent") is None
|
||||
|
||||
|
||||
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_list_all(self, registry, sample_module):
|
||||
"""列出所有模块"""
|
||||
registry.register(sample_module)
|
||||
registry.register(Module(name="second_mod"))
|
||||
|
||||
def test_filter_by_active(self):
|
||||
registry = ModuleRegistry()
|
||||
registry.register(Module(name="active_mod")) # 自动激活
|
||||
disabled = Module(name="disabled_mod")
|
||||
registry.register(disabled)
|
||||
disabled.disable()
|
||||
all_mods = registry.list_modules()
|
||||
assert len(all_mods) == 2
|
||||
|
||||
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()
|
||||
def test_list_filter_by_status(self, registry, sample_module):
|
||||
"""按状态过滤"""
|
||||
sample_module.status = ModuleStatus.DISABLED
|
||||
registry._modules["ai_voice"] = sample_module
|
||||
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"
|
||||
active_mods = registry.list_modules(status=ModuleStatus.ACTIVE)
|
||||
assert len(active_mods) == 1
|
||||
assert active_mods[0].name == "active_mod"
|
||||
|
||||
def test_filter_registered(self):
|
||||
registry = ModuleRegistry()
|
||||
# 有依赖未满足的模块保持 REGISTERED
|
||||
mod = Module(name="waiting_mod", dependencies=["missing"])
|
||||
registry.register(mod)
|
||||
disabled_mods = registry.list_modules(status=ModuleStatus.DISABLED)
|
||||
assert len(disabled_mods) == 1
|
||||
assert disabled_mods[0].name == "ai_voice"
|
||||
|
||||
registered = registry.list_modules(status=ModuleStatus.REGISTERED)
|
||||
assert len(registered) == 1
|
||||
assert registered[0].name == "waiting_mod"
|
||||
def test_list_empty(self, registry):
|
||||
"""空注册中心"""
|
||||
assert registry.list_modules() == []
|
||||
|
||||
|
||||
# ============================================================
|
||||
# ModuleRegistry - 能力发现
|
||||
# ============================================================
|
||||
class TestModuleRegistryCapability:
|
||||
"""能力查询测试"""
|
||||
|
||||
|
||||
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")],
|
||||
)
|
||||
)
|
||||
def test_has_capability_true(self, registry, sample_module):
|
||||
"""有指定能力"""
|
||||
registry.register(sample_module)
|
||||
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_false(self, registry):
|
||||
"""没有指定能力"""
|
||||
assert registry.has_capability("nonexistent_cap") 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_has_capability_inactive_module(self, registry, sample_module):
|
||||
"""未激活模块的能力不算"""
|
||||
sample_module.status = ModuleStatus.DISABLED
|
||||
registry._modules["ai_voice"] = sample_module
|
||||
assert registry.has_capability("generate_voice") 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]))
|
||||
def test_get_capability_success(self, registry, sample_module):
|
||||
"""获取能力定义"""
|
||||
registry.register(sample_module)
|
||||
cap = registry.get_capability("generate_voice")
|
||||
assert cap is not None
|
||||
assert cap.name == "generate_voice"
|
||||
assert len(cap.quota_rules) == 1
|
||||
|
||||
result = registry.get_capability("export")
|
||||
assert result is not None
|
||||
assert result.name == "export"
|
||||
# 返回第一个匹配的(mod1)
|
||||
assert result.description == "导出1"
|
||||
def test_get_capability_not_found(self, registry):
|
||||
"""获取不存在的能力返回 None"""
|
||||
assert registry.get_capability("nonexistent") is None
|
||||
|
||||
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, registry, sample_module):
|
||||
"""获取配额规则"""
|
||||
registry.register(sample_module)
|
||||
rules = registry.get_quota_rules("generate_voice")
|
||||
assert len(rules) == 1
|
||||
assert rules[0].dimension == "ai_voice_credits"
|
||||
assert rules[0].per_operation == 1.0
|
||||
|
||||
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") == []
|
||||
def test_get_quota_rules_empty(self, registry):
|
||||
"""不存在的能力返回空列表"""
|
||||
rules = registry.get_quota_rules("nonexistent")
|
||||
assert rules == []
|
||||
|
||||
|
||||
# ============================================================
|
||||
# ModuleRegistry - get_active_capabilities
|
||||
# ============================================================
|
||||
class TestModuleRegistryDependencies:
|
||||
"""依赖检查测试"""
|
||||
|
||||
def test_check_dependencies_met(self, registry):
|
||||
"""依赖满足返回 True"""
|
||||
base = Module(name="base")
|
||||
dep = Module(name="dep", dependencies=["base"])
|
||||
|
||||
registry.register(base)
|
||||
registry.register(dep)
|
||||
|
||||
assert registry.check_dependencies("dep") is True
|
||||
|
||||
def test_check_dependencies_not_met(self, registry):
|
||||
"""依赖不满足返回 False"""
|
||||
dep = Module(name="dep", dependencies=["missing"])
|
||||
registry._modules["dep"] = dep
|
||||
|
||||
assert registry.check_dependencies("dep") is False
|
||||
|
||||
def test_check_dependencies_module_not_found(self, registry):
|
||||
"""模块不存在返回 False"""
|
||||
assert registry.check_dependencies("nonexistent") is False
|
||||
|
||||
def test_check_dependencies_inactive_dep(self, registry):
|
||||
"""依赖模块未激活返回 False"""
|
||||
base = Module(name="base", status=ModuleStatus.DISABLED)
|
||||
dep = Module(name="dep", dependencies=["base"])
|
||||
|
||||
registry._modules["base"] = base
|
||||
registry._modules["dep"] = dep
|
||||
|
||||
assert registry.check_dependencies("dep") is False
|
||||
|
||||
|
||||
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()
|
||||
def test_get_active_capabilities(self, registry, sample_module):
|
||||
"""获取所有已激活模块的能力"""
|
||||
registry.register(sample_module)
|
||||
registry.register(
|
||||
Module(
|
||||
name="voice_mod",
|
||||
name="video_edit",
|
||||
capabilities=[
|
||||
ModuleCapability(name="generate_voice"),
|
||||
ModuleCapability(name="clone_voice"),
|
||||
ModuleCapability(name="cut_video"),
|
||||
ModuleCapability(name="add_effect"),
|
||||
],
|
||||
)
|
||||
)
|
||||
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")],
|
||||
)
|
||||
caps = registry.get_active_capabilities()
|
||||
assert "ai_voice" in caps
|
||||
assert "generate_voice" in caps["ai_voice"]
|
||||
assert "video_edit" in caps
|
||||
assert "cut_video" in caps["video_edit"]
|
||||
assert "add_effect" in caps["video_edit"]
|
||||
|
||||
def test_get_active_capabilities_empty(self, registry):
|
||||
"""空注册中心返回空字典"""
|
||||
assert registry.get_active_capabilities() == {}
|
||||
|
||||
def test_get_active_capabilities_excludes_inactive(self, registry):
|
||||
"""排除未激活模块"""
|
||||
mod = Module(
|
||||
name="test_mod",
|
||||
status=ModuleStatus.DISABLED,
|
||||
capabilities=[ModuleCapability(name="do_nothing")],
|
||||
)
|
||||
inactive = Module(
|
||||
name="inactive_mod",
|
||||
capabilities=[ModuleCapability(name="inactive_cap")],
|
||||
)
|
||||
registry.register(inactive)
|
||||
inactive.disable()
|
||||
registry._modules["test_mod"] = mod
|
||||
|
||||
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"}
|
||||
caps = registry.get_active_capabilities()
|
||||
assert "test_mod" not in caps
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 全局单例
|
||||
# ============================================================
|
||||
class TestModuleRegistryClear:
|
||||
"""清空注册中心测试"""
|
||||
|
||||
def test_clear(self, registry, sample_module):
|
||||
"""清空所有注册"""
|
||||
registry.register(sample_module)
|
||||
registry.register(Module(name="second"))
|
||||
|
||||
class TestGlobalSingleton:
|
||||
"""全局 module_registry 单例"""
|
||||
assert len(registry.list_modules()) == 2
|
||||
|
||||
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
|
||||
registry.clear()
|
||||
assert len(registry.list_modules()) == 0
|
||||
|
||||
Reference in New Issue
Block a user