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; /// /// DynamicMcpToolExtensions 单元测试。 /// 重点验证命名推导、描述生成、行为注解等纯逻辑,以及动态工具注册。 /// public class DynamicMcpToolExtensionsTests { #region 命名推导 /// /// DeriveServiceName:ITaskService → task /// [Fact] public void DeriveServiceName_ITaskService_ReturnsTask() { var result = DynamicMcpToolExtensions.DeriveServiceName(typeof(ITaskService)); Assert.Equal("task", result); } /// /// DeriveServiceName:复合接口名转 snake_case。 /// [Fact] public void DeriveServiceName_MultiWordInterface_ReturnsSnakeCase() { // 假设存在 ICloudSyncService → cloud_sync // 用动态类型模拟 var result = DynamicMcpToolExtensions.DeriveServiceName(typeof(ITestMultiWordService)); Assert.Equal("test_multi_word", result); } /// /// ToSnakeCase:PascalCase → snake_case /// [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 工具名组合 /// /// 完整工具名:prefix + 方法名 = task_get_all_tasks /// 验证 StripAsyncSuffix 和 ToSnakeCase 的组合效果。 /// [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); } /// /// StripAsyncSuffix:去掉 Async 后缀。 /// [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 描述生成 /// /// GetMethodDescription:方法名 → 中文描述 /// [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)); } /// /// GetMethodDescription:带 DescriptionAttribute 时优先使用。 /// [Fact] public void GetMethodDescription_UsesDescriptionAttribute() { var method = typeof(ITestAnnotatedService).GetMethod(nameof(ITestAnnotatedService.DoSomething))!; var desc = GetMethodDescription(method); Assert.Equal("执行自定义操作", desc); } #endregion #region 行为注解 /// /// IsReadOnlyMethod:Get/List/Query/Search 前缀返回 true。 /// [Fact] public void IsReadOnlyMethod_GetPrefix_ReturnsTrue() { Assert.True(IsReadOnlyMethod("GetAllTasksAsync")); Assert.True(IsReadOnlyMethod("ListSubTodosAsync")); Assert.True(IsReadOnlyMethod("QueryByDateAsync")); Assert.True(IsReadOnlyMethod("SearchByKeywordAsync")); } /// /// IsReadOnlyMethod:非只读前缀返回 false。 /// [Fact] public void IsReadOnlyMethod_NonReadPrefix_ReturnsFalse() { Assert.False(IsReadOnlyMethod("CreateTaskAsync")); Assert.False(IsReadOnlyMethod("UpdateTaskAsync")); Assert.False(IsReadOnlyMethod("DeleteTaskAsync")); Assert.False(IsReadOnlyMethod("ToggleCompleteAsync")); } /// /// IsDestructiveMethod:Delete/Remove 前缀返回 true。 /// [Fact] public void IsDestructiveMethod_DeletePrefix_ReturnsTrue() { Assert.True(IsDestructiveMethod("DeleteTaskAsync")); Assert.True(IsDestructiveMethod("RemoveItemAsync")); } /// /// IsDestructiveMethod:非破坏性操作返回 false。 /// [Fact] public void IsDestructiveMethod_NonDestructive_ReturnsFalse() { Assert.False(IsDestructiveMethod("GetAllTasksAsync")); Assert.False(IsDestructiveMethod("CreateTaskAsync")); Assert.False(IsDestructiveMethod("UpdateTaskAsync")); } #endregion #region 动态工具注册(集成测试) /// /// 验证 WithDynamicApiTools 扫描 IDynamicApiService 所在程序集,能发现所有实现接口并生成工具。 /// ITaskService(9)+ IVoiceService(4)+ IMeetingService(5)+ IAttachmentService(5)= 23。 /// CloudSync 服务接口已通过 [RemoteService(IsEnabled = false)] 排除。 /// [Fact] public void WithDynamicApiTools_ScansAssemblyAndRegistersTools() { var services = new ServiceCollection(); services.AddScoped(); services.AddLogging(); services.AddMcpServer().WithDynamicApiTools(); var provider = services.BuildServiceProvider(); var tools = provider.GetServices().ToList(); Assert.NotEmpty(tools); // ITaskService(9)+ IVoiceService(4)+ IMeetingService(5)+ IAttachmentService(5)= 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); } /// /// 验证生成的工具携带正确的行为注解(ReadOnly/Destructive)。 /// [Fact] public void WithDynamicApiTools_ToolsHaveCorrectBehaviorAnnotations() { // Arrange var services = new ServiceCollection(); services.AddScoped(); // Act services.AddMcpServer() .WithDynamicApiTools(); var provider = services.BuildServiceProvider(); var tools = provider.GetServices().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); } /// /// 验证所有生成的 MCP 工具都携带有意义的中文描述。 /// 描述不得为空,且至少包含一个中文字符。 /// [Fact] public void WithDynamicApiTools_AllToolsHaveMeaningfulDescriptions() { // Arrange var services = new ServiceCollection(); services.AddScoped(); // Act services.AddMcpServer() .WithDynamicApiTools(); var provider = services.BuildServiceProvider(); var tools = provider.GetServices().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}"); } } /// /// 验证有参数的工具(CreateTask、UpdateTask、ToggleComplete、DeleteTask、GetTaskById、GetSubTasks) /// 生成了 InputSchema;无参数的工具(GetAllTasks、GetActiveTasks、GetCompletedTasks) /// 的 InputSchema 为默认空结构。 /// [Fact] public void WithDynamicApiTools_ToolsHaveInputSchemas() { // Arrange var services = new ServiceCollection(); services.AddScoped(); // Act services.AddMcpServer() .WithDynamicApiTools(); var provider = services.BuildServiceProvider(); var tools = provider.GetServices().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 类型"); } } /// /// 通过 DI 直接解析 MockTaskService 并调用其方法, /// 验证服务实现可正常工作(返回预期类型与值)。 /// [Fact] public async Task MockTaskService_Invocation_ReturnsExpectedResults() { // Arrange var services = new ServiceCollection(); services.AddScoped(); var provider = services.BuildServiceProvider(); var service = provider.GetRequiredService(); // 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); } /// /// 验证 MCP 工具可被调用(通过 McpServerTool.InvokeAsync)。 /// 创建一个最小化的 RequestContext 并调用 task_get_all_tasks 工具。 /// [Fact] public async Task WithDynamicApiTools_InvokeTaskGetAllTasks_ReturnsResult() { // Arrange var services = new ServiceCollection(); services.AddScoped(); services.AddMcpServer() .WithDynamicApiTools(); var provider = services.BuildServiceProvider(); var tool = provider.GetServices() .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; 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(); 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 测试用类型 /// /// Mock 多词接口,用于测试 snake_case 转换。 /// public interface ITestMultiWordService : IDynamicApiService { Task GetValueAsync(); } /// /// Mock 带 DescriptionAttribute 的接口。 /// public interface ITestAnnotatedService : IDynamicApiService { [Description("执行自定义操作")] Task DoSomething(); } /// /// Mock 动态 API 服务接口(含简单参数方法)。 /// public interface IMockTestService : IDynamicApiService { [Description("获取数据")] Task GetDataAsync(); [Description("更新数据")] Task UpdateDataAsync(int id, string value); } /// /// Mock 动态 API 服务实现。 /// public class MockTestServiceImpl : IMockTestService { public Task GetDataAsync() => Task.FromResult("mock data"); public Task UpdateDataAsync(int id, string value) => Task.CompletedTask; } /// /// Mock ITaskService 实现(桩实现,仅用于验证工具注册,不执行实际逻辑)。 /// public class MockTaskService : ITaskService { public Task> GetAllTasksAsync() => Task.FromResult(new List()); public Task GetTaskByIdAsync(Guid id) => Task.FromResult(null); public Task> GetActiveTasksAsync() => Task.FromResult(new List()); public Task> GetCompletedTasksAsync() => Task.FromResult(new List()); public Task CreateTaskAsync(CreateTaskDto dto) => Task.FromResult(new TaskDto()); public Task UpdateTaskAsync(UpdateTaskDto dto) => Task.FromResult(new TaskDto()); public Task ToggleCompleteAsync(Guid id) => Task.FromResult(new TaskDto()); public Task DeleteTaskAsync(Guid id) => Task.CompletedTask; public Task> GetSubTasksAsync(Guid parentTaskId) => Task.FromResult(new List()); } #endregion