Phase 6: 一键多开 (工具组合) 功能

- 数据模型: ToolItem 新增 IsGroup(bool) + SubToolIds(List<string>) 字段

- 执行逻辑: ProcessExecutionService 改为 ExecuteAsync, 组合卡片遍历子工具逐一启动(500ms延迟), 孤儿ID跳过并打印警告

- 组合编辑: GroupEditViewModel + GroupEditWindow, 复选框列表勾选非组合工具

- 主界面: 标题栏新增 '+添加组合' 按钮(蓝色), 组合卡片右下角显示 📦 角标

- 右键菜单: 区分 '编辑工具' (普通) 和 '编辑组合' (IsGroup=true)

- 快捷键: HotKeyManager 适配 ExecuteAsync 异步调用

- 测试: 82 tests total (ProcessExecution 4->6, GroupEdit 5 new)
This commit is contained in:
2026-05-10 00:15:39 +08:00
parent 599964f078
commit 2c985e8d63
14 changed files with 668 additions and 31 deletions

View File

@@ -0,0 +1,97 @@
using Moq;
using PersonalToolBox.Models;
using PersonalToolBox.Services;
using PersonalToolBox.ViewModels;
namespace PersonalToolBox.Tests.ViewModels;
public class GroupEditViewModelTests
{
private readonly Mock<IDataService> _dataServiceMock = new();
private readonly Mock<ILogService> _logServiceMock = new();
private readonly AppConfig _config;
public GroupEditViewModelTests()
{
_config = new AppConfig
{
Tools =
[
new() { Id = "t1", Name = "VS Code", IsValid = true },
new() { Id = "t2", Name = "Postman", IsValid = true },
new() { Id = "t3", Name = "SomeGroup", IsGroup = true, SubToolIds = new List<string> { "t1" }, IsValid = true }
]
};
_dataServiceMock.Setup(d => d.Config).Returns(_config);
}
[Fact]
public void Constructor_AddMode_LoadsOnlyNonGroupTools()
{
var vm = new GroupEditViewModel(_dataServiceMock.Object, _logServiceMock.Object);
Assert.Equal("添加组合", vm.WindowTitle);
Assert.Equal(2, vm.AvailableTools.Count); // t1, t2 only (t3 is a group)
}
[Fact]
public void Constructor_EditMode_PreselectedTools()
{
var group = new ToolItem
{
Name = "ExistingGroup",
IsGroup = true,
SubToolIds = new List<string> { "t1" }
};
var vm = new GroupEditViewModel(_dataServiceMock.Object, _logServiceMock.Object, group);
Assert.Equal("编辑组合", vm.WindowTitle);
Assert.Equal("ExistingGroup", vm.Name);
Assert.True(vm.AvailableTools.First(t => t.Tool.Id == "t1").IsSelected);
Assert.False(vm.AvailableTools.First(t => t.Tool.Id == "t2").IsSelected);
}
[Fact]
public void Save_EmptyName_LogsWarning()
{
var vm = new GroupEditViewModel(_dataServiceMock.Object, _logServiceMock.Object);
vm.SaveCommand.Execute(null);
_logServiceMock.Verify(x => x.Warning(It.Is<string>(s => s.Contains("名称不能为空"))), Times.Once);
}
[Fact]
public void Save_AddMode_CreatesGroupAndCloses()
{
var vm = new GroupEditViewModel(_dataServiceMock.Object, _logServiceMock.Object)
{
Name = "NewGroup"
};
vm.AvailableTools.First(t => t.Tool.Id == "t1").IsSelected = true;
bool? closeResult = null;
vm.CloseAction = (r) => closeResult = r;
vm.SaveCommand.Execute(null);
Assert.True(vm.Saved);
Assert.True(closeResult);
Assert.Single(_config.Tools.Where(t => t.IsGroup && t.Name == "NewGroup"));
Assert.Contains("t1", _config.Tools.First(t => t.Name == "NewGroup").SubToolIds);
_dataServiceMock.Verify(x => x.Save(), Times.Once);
}
[Fact]
public void Cancel_ClosesWithoutSaving()
{
var vm = new GroupEditViewModel(_dataServiceMock.Object, _logServiceMock.Object);
bool? closeResult = null;
vm.CloseAction = (r) => closeResult = r;
vm.CancelCommand.Execute(null);
Assert.False(vm.Saved);
Assert.False(closeResult);
}
}