Files
Hua.Todo/test/Hua.Todo.Host.Tests/DynamicMcpToolExtensionsTests.cs
ShaoHua 4fe0b5a963 feat: 完成云同步、语音控制与多平台扩展基础架构搭建
本次提交完成了项目核心基础架构升级:
1. 新增动态API中间件与权限控制系统,支持匿名/鉴权接口分离
2. 搭建云同步服务体系,包含认证、任务同步、安全策略等核心模块
3. 实现语音控制全链路,从STT/意图解析到命令执行
4. 新增任务类型、附件实体与相关仓储接口
5. 重构前端配置与代理规则,统一后端端口为5057
6. 新增多平台测试项目与CI脚本优化
7. 完善项目文档与代码注释规范

移除了旧版迁移文件与冗余代理配置,调整项目结构适配跨平台部署需求。
2026-06-21 03:26:04 +08:00

493 lines
19 KiB
C#
Raw Permalink 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.
using System.ComponentModel;
using System.Reflection;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using Hua.Todo.HttpApi.AspNetCore.Mcp;
using Hua.Todo.HttpApi.Interfaces;
using Hua.Todo.Application.Models;
using Hua.Todo.Application.Services.Interfaces;
using Xunit;
namespace Hua.Todo.Host.Tests;
/// <summary>
/// DynamicMcpToolExtensions 单元测试。
/// 重点验证命名推导、描述生成、行为注解等纯逻辑,以及动态工具注册。
/// </summary>
public class DynamicMcpToolExtensionsTests
{
#region
/// <summary>
/// DeriveServiceNameITaskService → task
/// </summary>
[Fact]
public void DeriveServiceName_ITaskService_ReturnsTask()
{
var result = DynamicMcpToolExtensions.DeriveServiceName(typeof(ITaskService));
Assert.Equal("task", result);
}
/// <summary>
/// DeriveServiceName:复合接口名转 snake_case。
/// </summary>
[Fact]
public void DeriveServiceName_MultiWordInterface_ReturnsSnakeCase()
{
// 假设存在 ICloudSyncService → cloud_sync
// 用动态类型模拟
var result = DynamicMcpToolExtensions.DeriveServiceName(typeof(ITestMultiWordService));
Assert.Equal("test_multi_word", result);
}
/// <summary>
/// ToSnakeCasePascalCase → snake_case
/// </summary>
[Fact]
public void ToSnakeCase_PascalCase_ConvertsToSnakeCase()
{
Assert.Equal("get_all", DynamicMcpToolExtensions.ToSnakeCase("GetAll"));
Assert.Equal("create_task", DynamicMcpToolExtensions.ToSnakeCase("CreateTask"));
Assert.Equal("toggle_complete", DynamicMcpToolExtensions.ToSnakeCase("ToggleComplete"));
Assert.Equal("id", DynamicMcpToolExtensions.ToSnakeCase("Id"));
Assert.Equal("", DynamicMcpToolExtensions.ToSnakeCase(""));
}
#endregion
#region
/// <summary>
/// 完整工具名:prefix + 方法名 = task_get_all_tasks
/// 验证 StripAsyncSuffix 和 ToSnakeCase 的组合效果。
/// </summary>
[Fact]
public void ToolNaming_PrefixAndMethod_FormatsCorrectly()
{
var prefix = DynamicMcpToolExtensions.DeriveServiceName(typeof(ITaskService));
Assert.Equal("task", prefix);
// GetActiveTasksAsync → get_active_tasks
var methodSuffix = DynamicMcpToolExtensions.ToSnakeCase(
StripAsyncSuffix("GetActiveTasksAsync"));
Assert.Equal("get_active_tasks", methodSuffix);
}
/// <summary>
/// StripAsyncSuffix:去掉 Async 后缀。
/// </summary>
[Fact]
public void StripAsyncSuffix_RemovesAsync()
{
Assert.Equal("GetAllTasks", StripAsyncSuffix("GetAllTasksAsync"));
Assert.Equal("Create", StripAsyncSuffix("CreateAsync"));
Assert.Equal("Toggle", StripAsyncSuffix("ToggleAsync"));
Assert.Equal("Delete", StripAsyncSuffix("DeleteAsync"));
Assert.Equal("GetById", StripAsyncSuffix("GetById")); // 无 Async 后缀不修改
}
#endregion
#region
/// <summary>
/// GetMethodDescription:方法名 → 中文描述
/// </summary>
[Fact]
public void GetMethodDescription_GeneratesChineseDescription()
{
var getMethod = typeof(ITaskService).GetMethod(nameof(ITaskService.GetAllTasksAsync))!;
var createMethod = typeof(ITaskService).GetMethod(nameof(ITaskService.CreateTaskAsync))!;
var updateMethod = typeof(ITaskService).GetMethod(nameof(ITaskService.UpdateTaskAsync))!;
var deleteMethod = typeof(ITaskService).GetMethod(nameof(ITaskService.DeleteTaskAsync))!;
var toggleMethod = typeof(ITaskService).GetMethod(nameof(ITaskService.ToggleCompleteAsync))!;
Assert.StartsWith("获取", GetMethodDescription(getMethod));
Assert.StartsWith("创建", GetMethodDescription(createMethod));
Assert.StartsWith("更新", GetMethodDescription(updateMethod));
Assert.StartsWith("删除", GetMethodDescription(deleteMethod));
Assert.StartsWith("切换", GetMethodDescription(toggleMethod));
}
/// <summary>
/// GetMethodDescription:带 DescriptionAttribute 时优先使用。
/// </summary>
[Fact]
public void GetMethodDescription_UsesDescriptionAttribute()
{
var method = typeof(ITestAnnotatedService).GetMethod(nameof(ITestAnnotatedService.DoSomething))!;
var desc = GetMethodDescription(method);
Assert.Equal("执行自定义操作", desc);
}
#endregion
#region
/// <summary>
/// IsReadOnlyMethodGet/List/Query/Search 前缀返回 true。
/// </summary>
[Fact]
public void IsReadOnlyMethod_GetPrefix_ReturnsTrue()
{
Assert.True(IsReadOnlyMethod("GetAllTasksAsync"));
Assert.True(IsReadOnlyMethod("ListSubTodosAsync"));
Assert.True(IsReadOnlyMethod("QueryByDateAsync"));
Assert.True(IsReadOnlyMethod("SearchByKeywordAsync"));
}
/// <summary>
/// IsReadOnlyMethod:非只读前缀返回 false。
/// </summary>
[Fact]
public void IsReadOnlyMethod_NonReadPrefix_ReturnsFalse()
{
Assert.False(IsReadOnlyMethod("CreateTaskAsync"));
Assert.False(IsReadOnlyMethod("UpdateTaskAsync"));
Assert.False(IsReadOnlyMethod("DeleteTaskAsync"));
Assert.False(IsReadOnlyMethod("ToggleCompleteAsync"));
}
/// <summary>
/// IsDestructiveMethodDelete/Remove 前缀返回 true。
/// </summary>
[Fact]
public void IsDestructiveMethod_DeletePrefix_ReturnsTrue()
{
Assert.True(IsDestructiveMethod("DeleteTaskAsync"));
Assert.True(IsDestructiveMethod("RemoveItemAsync"));
}
/// <summary>
/// IsDestructiveMethod:非破坏性操作返回 false。
/// </summary>
[Fact]
public void IsDestructiveMethod_NonDestructive_ReturnsFalse()
{
Assert.False(IsDestructiveMethod("GetAllTasksAsync"));
Assert.False(IsDestructiveMethod("CreateTaskAsync"));
Assert.False(IsDestructiveMethod("UpdateTaskAsync"));
}
#endregion
#region
/// <summary>
/// 验证 WithDynamicApiTools 扫描 IDynamicApiService 所在程序集,能发现所有实现接口并生成工具。
/// ITaskService9+ IVoiceService4+ IMeetingService5+ IAttachmentService5= 23。
/// CloudSync 服务接口已通过 [RemoteService(IsEnabled = false)] 排除。
/// </summary>
[Fact]
public void WithDynamicApiTools_ScansAssemblyAndRegistersTools()
{
var services = new ServiceCollection();
services.AddScoped<ITaskService, MockTaskService>();
services.AddLogging();
services.AddMcpServer().WithDynamicApiTools();
var provider = services.BuildServiceProvider();
var tools = provider.GetServices<McpServerTool>().ToList();
Assert.NotEmpty(tools);
// ITaskService9+ IVoiceService4+ IMeetingService5+ IAttachmentService5= 23
Assert.Equal(23, tools.Count);
// ITaskService 工具名应与推导规则一致
var toolNames = tools.Select(t => t.ProtocolTool.Name).OrderBy(n => n).ToList();
Assert.Contains("task_get_all_tasks", toolNames);
Assert.Contains("task_get_task_by_id", toolNames);
Assert.Contains("task_get_active_tasks", toolNames);
Assert.Contains("task_get_completed_tasks", toolNames);
Assert.Contains("task_create_task", toolNames);
Assert.Contains("task_update_task", toolNames);
Assert.Contains("task_toggle_complete", toolNames);
Assert.Contains("task_delete_task", toolNames);
Assert.Contains("task_get_sub_tasks", toolNames);
// IVoiceService 工具名也应存在
Assert.Contains("voice_execute_command", toolNames);
Assert.Contains("voice_confirm_command", toolNames);
Assert.Contains("voice_get_breakdown", toolNames);
Assert.Contains("voice_confirm_breakdown", toolNames);
}
/// <summary>
/// 验证生成的工具携带正确的行为注解(ReadOnly/Destructive)。
/// </summary>
[Fact]
public void WithDynamicApiTools_ToolsHaveCorrectBehaviorAnnotations()
{
// Arrange
var services = new ServiceCollection();
services.AddScoped<ITaskService, MockTaskService>();
// Act
services.AddMcpServer()
.WithDynamicApiTools();
var provider = services.BuildServiceProvider();
var tools = provider.GetServices<McpServerTool>().ToList();
// Assert
var getTool = tools.First(t => t.ProtocolTool.Name == "task_get_all_tasks");
Assert.True(getTool.ProtocolTool.Annotations?.ReadOnlyHint == true);
var deleteTool = tools.First(t => t.ProtocolTool.Name == "task_delete_task");
Assert.True(deleteTool.ProtocolTool.Annotations?.DestructiveHint == true);
}
/// <summary>
/// 验证所有生成的 MCP 工具都携带有意义的中文描述。
/// 描述不得为空,且至少包含一个中文字符。
/// </summary>
[Fact]
public void WithDynamicApiTools_AllToolsHaveMeaningfulDescriptions()
{
// Arrange
var services = new ServiceCollection();
services.AddScoped<ITaskService, MockTaskService>();
// Act
services.AddMcpServer()
.WithDynamicApiTools();
var provider = services.BuildServiceProvider();
var tools = provider.GetServices<McpServerTool>().ToList();
// Assert: 每个工具的描述非空且包含中文
foreach (var tool in tools)
{
var desc = tool.ProtocolTool.Description;
Assert.False(string.IsNullOrWhiteSpace(desc),
$"工具 {tool.ProtocolTool.Name} 的描述不应为空");
// 验证包含中文字符(Unicode 范围 CJK Unified Ideographs
var hasChinese = desc.Any(c => c >= 0x4E00 && c <= 0x9FFF);
Assert.True(hasChinese,
$"工具 {tool.ProtocolTool.Name} 的描述应包含中文,当前: {desc}");
}
}
/// <summary>
/// 验证有参数的工具(CreateTask、UpdateTask、ToggleComplete、DeleteTask、GetTaskById、GetSubTasks
/// 生成了 InputSchema;无参数的工具(GetAllTasks、GetActiveTasks、GetCompletedTasks
/// 的 InputSchema 为默认空结构。
/// </summary>
[Fact]
public void WithDynamicApiTools_ToolsHaveInputSchemas()
{
// Arrange
var services = new ServiceCollection();
services.AddScoped<ITaskService, MockTaskService>();
// Act
services.AddMcpServer()
.WithDynamicApiTools();
var provider = services.BuildServiceProvider();
var tools = provider.GetServices<McpServerTool>().ToList();
// 有参数的方法应生成非空 InputSchema
var toolsWithParams = new[] { "task_create_task", "task_update_task", "task_toggle_complete",
"task_delete_task", "task_get_task_by_id", "task_get_sub_tasks" };
foreach (var name in toolsWithParams)
{
var tool = tools.First(t => t.ProtocolTool.Name == name);
var schema = tool.ProtocolTool.InputSchema;
Assert.True(schema.ValueKind != JsonValueKind.Undefined,
$"工具 {name} 应有 InputSchema");
}
// 无参数的方法应有空 InputSchema(SDK 默认行为)
var toolsWithoutParams = new[] { "task_get_all_tasks", "task_get_active_tasks", "task_get_completed_tasks" };
foreach (var name in toolsWithoutParams)
{
var tool = tools.First(t => t.ProtocolTool.Name == name);
// 无参数方法:InputSchema 可能为 Undefined 或空对象
Assert.True(tool.ProtocolTool.InputSchema.ValueKind == JsonValueKind.Undefined
|| (tool.ProtocolTool.InputSchema.ValueKind == JsonValueKind.Object
&& tool.ProtocolTool.InputSchema.GetProperty("type").GetString() == "object"),
$"无参数工具 {name} 的 InputSchema 应为 Undefined 或空 object 类型");
}
}
/// <summary>
/// 通过 DI 直接解析 MockTaskService 并调用其方法,
/// 验证服务实现可正常工作(返回预期类型与值)。
/// </summary>
[Fact]
public async Task MockTaskService_Invocation_ReturnsExpectedResults()
{
// Arrange
var services = new ServiceCollection();
services.AddScoped<ITaskService, MockTaskService>();
var provider = services.BuildServiceProvider();
var service = provider.GetRequiredService<ITaskService>();
// Act & Assert: 无参查询方法
var allTasks = await service.GetAllTasksAsync();
Assert.NotNull(allTasks);
Assert.Empty(allTasks);
var activeTasks = await service.GetActiveTasksAsync();
Assert.NotNull(activeTasks);
Assert.Empty(activeTasks);
var completedTasks = await service.GetCompletedTasksAsync();
Assert.NotNull(completedTasks);
Assert.Empty(completedTasks);
// Act & Assert: 单参查询方法
var testId = Guid.NewGuid();
var taskById = await service.GetTaskByIdAsync(testId);
Assert.Null(taskById);
var subTasks = await service.GetSubTasksAsync(testId);
Assert.NotNull(subTasks);
Assert.Empty(subTasks);
// Act & Assert: 创建任务
var created = await service.CreateTaskAsync(new CreateTaskDto { Title = "测试任务" });
Assert.NotNull(created);
// Act & Assert: 更新任务
var updated = await service.UpdateTaskAsync(new UpdateTaskDto { Id = testId, Title = "已更新" });
Assert.NotNull(updated);
// Act & Assert: 切换完成状态
var toggled = await service.ToggleCompleteAsync(testId);
Assert.NotNull(toggled);
// Act & Assert: 删除任务(void 方法不抛异常即为成功)
await service.DeleteTaskAsync(testId);
}
/// <summary>
/// 验证 MCP 工具可被调用(通过 McpServerTool.InvokeAsync)。
/// 创建一个最小化的 RequestContext 并调用 task_get_all_tasks 工具。
/// </summary>
[Fact]
public async Task WithDynamicApiTools_InvokeTaskGetAllTasks_ReturnsResult()
{
// Arrange
var services = new ServiceCollection();
services.AddScoped<ITaskService, MockTaskService>();
services.AddMcpServer()
.WithDynamicApiTools();
var provider = services.BuildServiceProvider();
var tool = provider.GetServices<McpServerTool>()
.First(t => t.ProtocolTool.Name == "task_get_all_tasks");
// 通过 RuntimeHelpers 创建未初始化的 RequestContext
// 然后手动注入 ServiceProvider(工具调用只需 Services 属性)。
var ctxType = typeof(RequestContext<>).MakeGenericType(typeof(CallToolRequestParams));
var requestContext = System.Runtime.CompilerServices.RuntimeHelpers
.GetUninitializedObject(ctxType) as RequestContext<CallToolRequestParams>;
Assert.NotNull(requestContext);
// 注入 ServiceProvider 以便 createTargetFunc 能解析 ITaskService
typeof(MessageContext).GetProperty("Services")!.SetValue(requestContext, provider);
// Act
var result = await tool.InvokeAsync(requestContext!, CancellationToken.None);
// Assert
Assert.NotNull(result);
Assert.False(result.IsError == true, $"调用应成功");
Assert.NotNull(result.Content);
Assert.NotEmpty(result.Content);
}
#endregion
#region private
private static string StripAsyncSuffix(string name)
=> name.EndsWith("Async") ? name[..^5] : name;
private static bool IsReadOnlyMethod(string name)
=> name.StartsWith("Get") || name.StartsWith("List")
|| name.StartsWith("Query") || name.StartsWith("Search");
private static bool IsDestructiveMethod(string name)
=> name.StartsWith("Delete") || name.StartsWith("Remove");
private static string GetMethodDescription(MethodInfo method)
{
var descAttr = method.GetCustomAttribute<DescriptionAttribute>();
if (descAttr != null) return descAttr.Description;
var name = method.Name;
if (name.StartsWith("Get")) return $"获取{StripAsyncSuffix(name[3..])}";
if (name.StartsWith("Create")) return $"创建{StripAsyncSuffix(name[6..])}";
if (name.StartsWith("Update")) return $"更新{StripAsyncSuffix(name[6..])}";
if (name.StartsWith("Delete")) return $"删除{StripAsyncSuffix(name[6..])}";
if (name.StartsWith("Toggle")) return $"切换{StripAsyncSuffix(name[6..])}";
return StripAsyncSuffix(name);
}
#endregion
}
#region
/// <summary>
/// Mock 多词接口,用于测试 snake_case 转换。
/// </summary>
public interface ITestMultiWordService : IDynamicApiService
{
Task<int> GetValueAsync();
}
/// <summary>
/// Mock 带 DescriptionAttribute 的接口。
/// </summary>
public interface ITestAnnotatedService : IDynamicApiService
{
[Description("执行自定义操作")]
Task<int> DoSomething();
}
/// <summary>
/// Mock 动态 API 服务接口(含简单参数方法)。
/// </summary>
public interface IMockTestService : IDynamicApiService
{
[Description("获取数据")]
Task<string> GetDataAsync();
[Description("更新数据")]
Task UpdateDataAsync(int id, string value);
}
/// <summary>
/// Mock 动态 API 服务实现。
/// </summary>
public class MockTestServiceImpl : IMockTestService
{
public Task<string> GetDataAsync() => Task.FromResult("mock data");
public Task UpdateDataAsync(int id, string value) => Task.CompletedTask;
}
/// <summary>
/// Mock ITaskService 实现(桩实现,仅用于验证工具注册,不执行实际逻辑)。
/// </summary>
public class MockTaskService : ITaskService
{
public Task<List<TaskDto>> GetAllTasksAsync() => Task.FromResult(new List<TaskDto>());
public Task<TaskDto?> GetTaskByIdAsync(Guid id) => Task.FromResult<TaskDto?>(null);
public Task<List<TaskDto>> GetActiveTasksAsync() => Task.FromResult(new List<TaskDto>());
public Task<List<TaskDto>> GetCompletedTasksAsync() => Task.FromResult(new List<TaskDto>());
public Task<TaskDto> CreateTaskAsync(CreateTaskDto dto) => Task.FromResult(new TaskDto());
public Task<TaskDto> UpdateTaskAsync(UpdateTaskDto dto) => Task.FromResult(new TaskDto());
public Task<TaskDto> ToggleCompleteAsync(Guid id) => Task.FromResult(new TaskDto());
public Task DeleteTaskAsync(Guid id) => Task.CompletedTask;
public Task<List<TaskDto>> GetSubTasksAsync(Guid parentTaskId) => Task.FromResult(new List<TaskDto>());
}
#endregion