Files
Hua.Todo/src/Hua.Todo.Tests/DynamicMcpToolExtensionsTests.cs
T
ShaoHua 9223ceca50 feat(mcp): 新增 MCP 服务基础设施,重构规则文件序号,新增 v1.3.0 工单文档
- 规则重组:全局/ 下 8 个规则合并为 6 个(01+02→01,05+06→04),序号顺延

- 新增项目规则 05-多入口功能同步规范(UI/语音入口覆盖检查)

- 新增 MCP 服务基础设施:Mcp/ 目录(DI 注册、端点扩展、动态工具描述符)、单元测试

- v1.3.0 工单文档:03 系列(会议任务拆分)、04(富文本描述与附件管理)

- MCP 接口与前端集成指南:docs/manual/08、09
2026-06-16 01:15:40 +08:00

325 lines
12 KiB
C#
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.
using System.ComponentModel;
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Server;
using Hua.Todo.Application.Interfaces;
using Hua.Todo.Application.Mcp;
using Hua.Todo.Application.Models;
using Xunit;
namespace Hua.Todo.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 所在程序集,能发现 ITaskService 并生成工具。
/// 工具数量 = ITaskService 的公共方法数(9 个)。
/// </summary>
[Fact]
public void WithDynamicApiTools_ScansAssemblyAndRegistersTools()
{
// Arrange
var services = new ServiceCollection();
services.AddScoped<ITaskService, MockTaskService>();
// Act - 仅注册工具到 DI,不绑定传输层
services.AddMcpServer()
.WithDynamicApiTools();
var provider = services.BuildServiceProvider();
// Assert:验证 McpServerTool 实例已注册到 DI
var tools = provider.GetServices<McpServerTool>().ToList();
Assert.NotEmpty(tools);
// ITaskService 有 9 个公共方法
Assert.Equal(9, tools.Count);
// 工具名应与推导规则一致
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);
}
/// <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);
}
#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(int 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(int id) => Task.FromResult(new TaskDto());
public Task DeleteTaskAsync(int id) => Task.CompletedTask;
public Task<List<TaskDto>> GetSubTasksAsync(int parentTaskId) => Task.FromResult(new List<TaskDto>());
}
#endregion