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

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

554 lines
18 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 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