4fe0b5a963
本次提交完成了项目核心基础架构升级: 1. 新增动态API中间件与权限控制系统,支持匿名/鉴权接口分离 2. 搭建云同步服务体系,包含认证、任务同步、安全策略等核心模块 3. 实现语音控制全链路,从STT/意图解析到命令执行 4. 新增任务类型、附件实体与相关仓储接口 5. 重构前端配置与代理规则,统一后端端口为5057 6. 新增多平台测试项目与CI脚本优化 7. 完善项目文档与代码注释规范 移除了旧版迁移文件与冗余代理配置,调整项目结构适配跨平台部署需求。
554 lines
18 KiB
C#
554 lines
18 KiB
C#
using Hua.Todo.Application.Models;
|
||
using Hua.Todo.Application.Services.Interfaces;
|
||
using Hua.Todo.Application.Services.Voice;
|
||
using Hua.Todo.Application.Services.Voice.Models;
|
||
using Hua.Todo.Core.Entities;
|
||
using Hua.Todo.Core.Services;
|
||
using Hua.Todo.Core.Voice;
|
||
using Microsoft.Extensions.Logging.Abstractions;
|
||
using Xunit;
|
||
|
||
namespace Hua.Todo.Host.Tests;
|
||
|
||
/// <summary>
|
||
/// 语音意图解析与命令执行器单元测试。
|
||
/// 覆盖 RuleIntentParser(离线规则解析)、VoiceCommandExecutor(命令执行)、模型序列化。
|
||
/// </summary>
|
||
public class VoiceIntentParserTests
|
||
{
|
||
#region RuleIntentParser 测试
|
||
|
||
[Fact]
|
||
public void Parse_Create_ReturnsCreateIntent()
|
||
{
|
||
var parser = new RuleIntentParser();
|
||
var result = parser.ParseAsync("创建任务 开会").Result;
|
||
|
||
Assert.Equal(VoiceIntent.CREATE, result.Intent);
|
||
Assert.Equal("开会", result.Params["title"]);
|
||
Assert.True(result.Confidence >= 0.8);
|
||
}
|
||
|
||
[Fact]
|
||
public void Parse_Create_WithShortVerb_ReturnsCreateIntent()
|
||
{
|
||
var parser = new RuleIntentParser();
|
||
var result = parser.ParseAsync("加 测试").Result;
|
||
|
||
Assert.Equal(VoiceIntent.CREATE, result.Intent);
|
||
Assert.Equal("测试", result.Params["title"]);
|
||
}
|
||
|
||
[Fact]
|
||
public void Parse_Delete_ReturnsDeleteIntent()
|
||
{
|
||
var parser = new RuleIntentParser();
|
||
var result = parser.ParseAsync("删除任务 开会").Result;
|
||
|
||
Assert.Equal(VoiceIntent.DELETE, result.Intent);
|
||
Assert.Equal("开会", result.Params["targetTitle"]);
|
||
}
|
||
|
||
[Fact]
|
||
public void Parse_Complete_ReturnsCompleteIntent()
|
||
{
|
||
var parser = new RuleIntentParser();
|
||
var result = parser.ParseAsync("完成任务 开会").Result;
|
||
|
||
Assert.Equal(VoiceIntent.COMPLETE, result.Intent);
|
||
Assert.Equal("开会", result.Params["targetTitle"]);
|
||
}
|
||
|
||
[Fact]
|
||
public void Parse_Uncomplete_ReturnsUncompleteIntent()
|
||
{
|
||
var parser = new RuleIntentParser();
|
||
var result = parser.ParseAsync("取消完成 开会").Result;
|
||
|
||
Assert.Equal(VoiceIntent.UNCOMPLETE, result.Intent);
|
||
Assert.Equal("开会", result.Params["targetTitle"]);
|
||
}
|
||
|
||
[Fact]
|
||
public void Parse_Update_ReturnsUpdateIntent()
|
||
{
|
||
var parser = new RuleIntentParser();
|
||
var result = parser.ParseAsync("修改任务 开会 改为 团队会议").Result;
|
||
|
||
Assert.Equal(VoiceIntent.UPDATE, result.Intent);
|
||
Assert.Equal("开会", result.Params["targetTitle"]);
|
||
Assert.Equal("团队会议", result.Params["newTitle"]);
|
||
}
|
||
|
||
[Fact]
|
||
public void Parse_Query_ReturnsQueryIntent()
|
||
{
|
||
var parser = new RuleIntentParser();
|
||
var result = parser.ParseAsync("查看未完成任务").Result;
|
||
|
||
Assert.Equal(VoiceIntent.QUERY, result.Intent);
|
||
Assert.Equal("未完成任务", result.Params["filter"]);
|
||
}
|
||
|
||
[Fact]
|
||
public void Parse_Query_HighPriority_ReturnsQueryIntent()
|
||
{
|
||
var parser = new RuleIntentParser();
|
||
var result = parser.ParseAsync("列出高优先级任务").Result;
|
||
|
||
Assert.Equal(VoiceIntent.QUERY, result.Intent);
|
||
Assert.Equal("高优先级任务", result.Params["filter"]);
|
||
}
|
||
|
||
[Fact]
|
||
public void Parse_AddSubtask_ReturnsAddSubtaskIntent()
|
||
{
|
||
var parser = new RuleIntentParser();
|
||
var result = parser.ParseAsync("给任务开会添加子任务 准备PPT").Result;
|
||
|
||
Assert.Equal(VoiceIntent.ADD_SUBTASK, result.Intent);
|
||
Assert.Equal("任务开会", result.Params["parentTitle"]);
|
||
Assert.Equal("准备PPT", result.Params["subTitle"]);
|
||
}
|
||
|
||
[Fact]
|
||
public void Parse_Unknown_ReturnsUnknownIntent()
|
||
{
|
||
var parser = new RuleIntentParser();
|
||
var result = parser.ParseAsync("今天天气真好").Result;
|
||
|
||
Assert.Equal(VoiceIntent.UNKNOWN, result.Intent);
|
||
Assert.Equal(0.0, result.Confidence);
|
||
}
|
||
|
||
[Fact]
|
||
public void Parse_AiBreakdown_ReturnsAiBreakdownIntent()
|
||
{
|
||
var parser = new RuleIntentParser();
|
||
var result = parser.ParseAsync("帮我拆分 开会").Result;
|
||
|
||
Assert.Equal(VoiceIntent.AI_BREAKDOWN, result.Intent);
|
||
Assert.Equal("开会", result.Params["targetTitle"]);
|
||
}
|
||
|
||
[Fact]
|
||
public void Parse_EmptyText_ReturnsUnknown()
|
||
{
|
||
var parser = new RuleIntentParser();
|
||
var result = parser.ParseAsync(" ").Result;
|
||
|
||
Assert.Equal(VoiceIntent.UNKNOWN, result.Intent);
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region VoiceCommandExecutor 测试(Mock ITaskService)
|
||
|
||
[Fact]
|
||
public async Task Execute_Create_Success()
|
||
{
|
||
var mockService = new MockTaskServiceForVoice();
|
||
var executor = new VoiceCommandExecutor(mockService, NullLogger<VoiceCommandExecutor>.Instance);
|
||
|
||
var intentResult = new VoiceIntentResult
|
||
{
|
||
Intent = VoiceIntent.CREATE,
|
||
Params = new Dictionary<string, string> { ["title"] = "新任务" },
|
||
Confidence = 0.9
|
||
};
|
||
|
||
var response = await executor.ExecuteAsync(intentResult);
|
||
|
||
Assert.True(response.Result.Success);
|
||
Assert.Contains("已创建任务", response.Result.Message);
|
||
Assert.Single(mockService.CreatedTasks);
|
||
Assert.Equal("新任务", mockService.CreatedTasks[0].Title);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Execute_Create_WithPriority_Success()
|
||
{
|
||
var mockService = new MockTaskServiceForVoice();
|
||
var executor = new VoiceCommandExecutor(mockService, NullLogger<VoiceCommandExecutor>.Instance);
|
||
|
||
var intentResult = new VoiceIntentResult
|
||
{
|
||
Intent = VoiceIntent.CREATE,
|
||
Params = new Dictionary<string, string> { ["title"] = "紧急任务", ["priority"] = "高" },
|
||
Confidence = 0.9
|
||
};
|
||
|
||
var response = await executor.ExecuteAsync(intentResult);
|
||
|
||
Assert.True(response.Result.Success);
|
||
Assert.Equal(TaskPriority.High, mockService.CreatedTasks[0].Priority);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Execute_Delete_Success()
|
||
{
|
||
var mockService = new MockTaskServiceForVoice();
|
||
var taskId = mockService.AddExistingTask("测试任务", false);
|
||
var executor = new VoiceCommandExecutor(mockService, NullLogger<VoiceCommandExecutor>.Instance);
|
||
|
||
var intentResult = new VoiceIntentResult
|
||
{
|
||
Intent = VoiceIntent.DELETE,
|
||
Params = new Dictionary<string, string> { ["targetTitle"] = "测试任务" },
|
||
Confidence = 0.9
|
||
};
|
||
|
||
var response = await executor.ExecuteAsync(intentResult);
|
||
|
||
Assert.True(response.Result.Success);
|
||
Assert.Contains("已删除任务", response.Result.Message);
|
||
Assert.Single(mockService.DeletedIds);
|
||
Assert.Equal(taskId, mockService.DeletedIds[0]);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Execute_Complete_Success()
|
||
{
|
||
var mockService = new MockTaskServiceForVoice();
|
||
mockService.AddExistingTask("待完成", false);
|
||
var executor = new VoiceCommandExecutor(mockService, NullLogger<VoiceCommandExecutor>.Instance);
|
||
|
||
var intentResult = new VoiceIntentResult
|
||
{
|
||
Intent = VoiceIntent.COMPLETE,
|
||
Params = new Dictionary<string, string> { ["targetTitle"] = "待完成" },
|
||
Confidence = 0.9
|
||
};
|
||
|
||
var response = await executor.ExecuteAsync(intentResult);
|
||
|
||
Assert.True(response.Result.Success);
|
||
Assert.Contains("已完成任务", response.Result.Message);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Execute_Ambiguity_ReturnsCandidates()
|
||
{
|
||
var mockService = new MockTaskServiceForVoice();
|
||
mockService.AddExistingTask("开会");
|
||
mockService.AddExistingTask("开会讨论方案");
|
||
var executor = new VoiceCommandExecutor(mockService, NullLogger<VoiceCommandExecutor>.Instance);
|
||
|
||
var intentResult = new VoiceIntentResult
|
||
{
|
||
Intent = VoiceIntent.DELETE,
|
||
Params = new Dictionary<string, string> { ["targetTitle"] = "开会" },
|
||
Confidence = 0.9
|
||
};
|
||
|
||
var response = await executor.ExecuteAsync(intentResult);
|
||
|
||
Assert.False(response.Result.Success);
|
||
Assert.True(response.Result.Ambiguity);
|
||
Assert.Equal(2, response.Result.Candidates.Count);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Execute_NotFound_ReturnsError()
|
||
{
|
||
var mockService = new MockTaskServiceForVoice();
|
||
var executor = new VoiceCommandExecutor(mockService, NullLogger<VoiceCommandExecutor>.Instance);
|
||
|
||
var intentResult = new VoiceIntentResult
|
||
{
|
||
Intent = VoiceIntent.DELETE,
|
||
Params = new Dictionary<string, string> { ["targetTitle"] = "不存在的任务" },
|
||
Confidence = 0.9
|
||
};
|
||
|
||
var response = await executor.ExecuteAsync(intentResult);
|
||
|
||
Assert.False(response.Result.Success);
|
||
Assert.False(response.Result.Ambiguity);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Execute_AddSubTask_Success()
|
||
{
|
||
var mockService = new MockTaskServiceForVoice();
|
||
var parentTaskId = mockService.AddExistingTask("父任务");
|
||
var executor = new VoiceCommandExecutor(mockService, NullLogger<VoiceCommandExecutor>.Instance);
|
||
|
||
var intentResult = new VoiceIntentResult
|
||
{
|
||
Intent = VoiceIntent.ADD_SUBTASK,
|
||
Params = new Dictionary<string, string> { ["parentTitle"] = "父任务", ["subTitle"] = "子任务" },
|
||
Confidence = 0.9
|
||
};
|
||
|
||
var response = await executor.ExecuteAsync(intentResult);
|
||
|
||
Assert.True(response.Result.Success);
|
||
Assert.Contains("添加子任务", response.Result.Message);
|
||
Assert.Equal(parentTaskId, mockService.CreatedTasks[0].ParentTaskId);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Execute_Uncomplete_Success()
|
||
{
|
||
var mockService = new MockTaskServiceForVoice();
|
||
mockService.AddExistingTask("已完成的任务", true); // IsCompleted = true
|
||
var executor = new VoiceCommandExecutor(mockService, NullLogger<VoiceCommandExecutor>.Instance);
|
||
|
||
var intentResult = new VoiceIntentResult
|
||
{
|
||
Intent = VoiceIntent.UNCOMPLETE,
|
||
Params = new Dictionary<string, string> { ["targetTitle"] = "已完成的任务" },
|
||
Confidence = 0.9
|
||
};
|
||
|
||
var response = await executor.ExecuteAsync(intentResult);
|
||
|
||
Assert.True(response.Result.Success);
|
||
Assert.Contains("已取消完成", response.Result.Message);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Execute_Confirmation_Complete_Success()
|
||
{
|
||
var mockService = new MockTaskServiceForVoice();
|
||
var confirmTaskId = mockService.AddExistingTask("确认测试", false);
|
||
var executor = new VoiceCommandExecutor(mockService, NullLogger<VoiceCommandExecutor>.Instance);
|
||
|
||
var response = await executor.ExecuteConfirmedAsync(VoiceIntent.COMPLETE, confirmTaskId);
|
||
|
||
Assert.True(response.Result.Success);
|
||
Assert.Contains("已完成任务", response.Result.Message);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Execute_Confirmation_Delete_Success()
|
||
{
|
||
var mockService = new MockTaskServiceForVoice();
|
||
var deleteConfirmId = mockService.AddExistingTask("删除确认", false);
|
||
var executor = new VoiceCommandExecutor(mockService, NullLogger<VoiceCommandExecutor>.Instance);
|
||
|
||
var response = await executor.ExecuteConfirmedAsync(VoiceIntent.DELETE, deleteConfirmId);
|
||
|
||
Assert.True(response.Result.Success);
|
||
Assert.Contains("已删除任务", response.Result.Message);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Execute_UnknownIntent_ReturnsError()
|
||
{
|
||
var mockService = new MockTaskServiceForVoice();
|
||
var executor = new VoiceCommandExecutor(mockService, NullLogger<VoiceCommandExecutor>.Instance);
|
||
|
||
var intentResult = new VoiceIntentResult
|
||
{
|
||
Intent = VoiceIntent.UNKNOWN,
|
||
Confidence = 0.0
|
||
};
|
||
|
||
var response = await executor.ExecuteAsync(intentResult);
|
||
|
||
Assert.False(response.Result.Success);
|
||
Assert.Equal("没听懂,请再说一次", response.Result.Message);
|
||
Assert.Equal(VoiceIntent.UNKNOWN, response.Intent);
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region HybridVoiceIntentParser 离线降级测试
|
||
|
||
[Fact]
|
||
public async Task HybridParser_OfflineMode_UsesRuleParser()
|
||
{
|
||
// 设置离线模式环境变量
|
||
Environment.SetEnvironmentVariable("VOICE_ONLINE", "false");
|
||
try
|
||
{
|
||
var parser = new HybridVoiceIntentParser(null!, new RuleIntentParser(), NullLogger<HybridVoiceIntentParser>.Instance);
|
||
var result = await parser.ParseAsync("创建任务 测试");
|
||
|
||
Assert.Equal(VoiceIntent.CREATE, result.Intent);
|
||
Assert.Equal("测试", result.Params["title"]);
|
||
}
|
||
finally
|
||
{
|
||
Environment.SetEnvironmentVariable("VOICE_ONLINE", null);
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region AiBreakdownService 响应解析测试
|
||
|
||
[Fact]
|
||
public async Task AiBreakdown_NoTask_ReturnsEmptyList()
|
||
{
|
||
var mockService = new MockTaskServiceForVoice();
|
||
var mockLlm = new MockLlmClientService("");
|
||
var breakdownService = new AiBreakdownService(mockLlm, mockService, NullLogger<AiBreakdownService>.Instance);
|
||
|
||
var request = new AiBreakdownRequest { TaskId = Guid.NewGuid() };
|
||
|
||
var response = await breakdownService.GetBreakdownAsync(request);
|
||
|
||
Assert.NotNull(response);
|
||
Assert.Empty(response.Suggestions);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task AiBreakdown_Confirm_CreatesSubTasks()
|
||
{
|
||
var mockService = new MockTaskServiceForVoice();
|
||
var mockLlm = new MockLlmClientService("");
|
||
var breakdownService = new AiBreakdownService(mockLlm, mockService, NullLogger<AiBreakdownService>.Instance);
|
||
|
||
var confirmParentId = Guid.NewGuid();
|
||
var confirmRequest = new AiBreakdownConfirmRequest
|
||
{
|
||
ParentTaskId = confirmParentId,
|
||
SubTasks = new List<SubTaskItem>
|
||
{
|
||
new() { Title = "子任务1", Priority = TaskPriority.High },
|
||
new() { Title = "子任务2", Priority = TaskPriority.Medium }
|
||
}
|
||
};
|
||
|
||
var created = await breakdownService.ConfirmBreakdownAsync(confirmRequest);
|
||
|
||
Assert.Equal(2, created.Count);
|
||
Assert.Equal("子任务1", created[0].Title);
|
||
Assert.Equal(TaskPriority.High, created[0].Priority);
|
||
Assert.Equal(confirmParentId, created[0].ParentTaskId);
|
||
}
|
||
|
||
#endregion
|
||
}
|
||
|
||
#region Mock 实现
|
||
|
||
/// <summary>
|
||
/// Mock ITaskService 实现,用于 VoiceCommandExecutor 和 AiBreakdownService 测试。
|
||
/// 跟踪创建、删除操作并支持预设任务数据。
|
||
/// </summary>
|
||
public class MockTaskServiceForVoice : ITaskService
|
||
{
|
||
private readonly List<TaskDto> _tasks = new();
|
||
private Guid _nextId = Guid.NewGuid();
|
||
|
||
/// <summary>
|
||
/// 跟踪已创建的任务。
|
||
/// </summary>
|
||
public List<TaskDto> CreatedTasks { get; } = new();
|
||
|
||
/// <summary>
|
||
/// 跟踪已删除的 ID。
|
||
/// </summary>
|
||
public List<Guid> DeletedIds { get; } = new();
|
||
|
||
/// <summary>
|
||
/// 预设一个已有任务,返回创建的任务 ID 供后续断言使用。
|
||
/// </summary>
|
||
public Guid AddExistingTask(string title, bool isCompleted = false)
|
||
{
|
||
var id = Guid.NewGuid();
|
||
_tasks.Add(new TaskDto
|
||
{
|
||
Id = id,
|
||
Title = title,
|
||
Priority = TaskPriority.Medium,
|
||
IsCompleted = isCompleted,
|
||
CreatedAt = DateTime.UtcNow,
|
||
UpdatedAt = DateTime.UtcNow,
|
||
SubTasks = new List<TaskDto>()
|
||
});
|
||
return id;
|
||
}
|
||
|
||
public Task<List<TaskDto>> GetAllTasksAsync() => Task.FromResult(_tasks.ToList());
|
||
public Task<TaskDto?> GetTaskByIdAsync(Guid id) => Task.FromResult(_tasks.FirstOrDefault(t => t.Id == id));
|
||
public Task<List<TaskDto>> GetActiveTasksAsync() => Task.FromResult(_tasks.Where(t => !t.IsCompleted).ToList());
|
||
public Task<List<TaskDto>> GetCompletedTasksAsync() => Task.FromResult(_tasks.Where(t => t.IsCompleted).ToList());
|
||
|
||
public Task<TaskDto> CreateTaskAsync(CreateTaskDto dto)
|
||
{
|
||
var task = new TaskDto
|
||
{
|
||
Id = Guid.NewGuid(),
|
||
Title = dto.Title,
|
||
Priority = dto.Priority,
|
||
IsCompleted = false,
|
||
CreatedAt = DateTime.UtcNow,
|
||
UpdatedAt = DateTime.UtcNow,
|
||
ParentTaskId = dto.ParentTaskId,
|
||
SubTasks = new List<TaskDto>()
|
||
};
|
||
_tasks.Add(task);
|
||
CreatedTasks.Add(task);
|
||
return Task.FromResult(task);
|
||
}
|
||
|
||
public Task<TaskDto> UpdateTaskAsync(UpdateTaskDto dto)
|
||
{
|
||
var task = _tasks.FirstOrDefault(t => t.Id == dto.Id);
|
||
if (task == null) throw new KeyNotFoundException($"Task with ID {dto.Id} not found");
|
||
|
||
if (!string.IsNullOrEmpty(dto.Title)) task.Title = dto.Title;
|
||
if (dto.Priority.HasValue) task.Priority = dto.Priority.Value;
|
||
task.UpdatedAt = DateTime.UtcNow;
|
||
|
||
return Task.FromResult(task);
|
||
}
|
||
|
||
public Task<TaskDto> ToggleCompleteAsync(Guid id)
|
||
{
|
||
var task = _tasks.FirstOrDefault(t => t.Id == id);
|
||
if (task == null) throw new KeyNotFoundException($"Task with ID {id} not found");
|
||
|
||
task.IsCompleted = !task.IsCompleted;
|
||
task.UpdatedAt = DateTime.UtcNow;
|
||
return Task.FromResult(task);
|
||
}
|
||
|
||
public Task DeleteTaskAsync(Guid id)
|
||
{
|
||
DeletedIds.Add(id);
|
||
_tasks.RemoveAll(t => t.Id == id);
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
public Task<List<TaskDto>> GetSubTasksAsync(Guid parentTaskId)
|
||
{
|
||
return Task.FromResult(_tasks.Where(t => t.ParentTaskId == parentTaskId).ToList());
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Mock ILlmClientService 实现,返回预设文本或抛出异常。
|
||
/// </summary>
|
||
public class MockLlmClientService : ILlmClientService
|
||
{
|
||
private readonly string _presetResponse;
|
||
|
||
/// <summary>
|
||
/// 创建 Mock LLM 客户端。
|
||
/// </summary>
|
||
/// <param name="presetResponse">预设的 LLM 响应文本。</param>
|
||
public MockLlmClientService(string presetResponse)
|
||
{
|
||
_presetResponse = presetResponse;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 返回预设响应(或抛出异常以模拟 LLM 不可用)。
|
||
/// </summary>
|
||
public Task<string> SendAsync(string prompt, CancellationToken ct = default)
|
||
{
|
||
if (string.IsNullOrEmpty(_presetResponse))
|
||
throw new HttpRequestException("LLM unavailable");
|
||
|
||
return Task.FromResult(_presetResponse);
|
||
}
|
||
}
|
||
|
||
#endregion
|