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:
@@ -7,19 +7,26 @@ namespace PersonalToolBox.Tests.Services;
|
||||
public class ProcessExecutionServiceTests
|
||||
{
|
||||
private readonly Mock<ILogService> _logServiceMock = new();
|
||||
private readonly Mock<IDataService> _dataServiceMock = new();
|
||||
private readonly AppConfig _config = new();
|
||||
|
||||
public ProcessExecutionServiceTests()
|
||||
{
|
||||
_dataServiceMock.Setup(d => d.Config).Returns(_config);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Execute_NullTool_LogsError()
|
||||
public async Task Execute_NullTool_LogsError()
|
||||
{
|
||||
var service = new ProcessExecutionService(_logServiceMock.Object);
|
||||
var service = new ProcessExecutionService(_logServiceMock.Object, _dataServiceMock.Object);
|
||||
|
||||
service.Execute(null!);
|
||||
await service.ExecuteAsync(null!);
|
||||
|
||||
_logServiceMock.Verify(x => x.Error(It.Is<string>(s => s.Contains("空工具项"))), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Execute_InvalidTool_LogsWarning()
|
||||
public async Task Execute_InvalidTool_LogsWarning()
|
||||
{
|
||||
var tool = new ToolItem
|
||||
{
|
||||
@@ -27,15 +34,15 @@ public class ProcessExecutionServiceTests
|
||||
ExecutablePath = @"C:\nonexistent.exe",
|
||||
IsValid = false
|
||||
};
|
||||
var service = new ProcessExecutionService(_logServiceMock.Object);
|
||||
var service = new ProcessExecutionService(_logServiceMock.Object, _dataServiceMock.Object);
|
||||
|
||||
service.Execute(tool);
|
||||
await service.ExecuteAsync(tool);
|
||||
|
||||
_logServiceMock.Verify(x => x.Warning(It.Is<string>(s => s.Contains("无法运行") && s.Contains("BadTool"))), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Execute_ProcessFails_LogsError()
|
||||
public async Task Execute_ProcessFails_LogsError()
|
||||
{
|
||||
var tool = new ToolItem
|
||||
{
|
||||
@@ -43,28 +50,71 @@ public class ProcessExecutionServiceTests
|
||||
ExecutablePath = @"Z:\impossible_path\not_really.exe",
|
||||
IsValid = true
|
||||
};
|
||||
var service = new ProcessExecutionService(_logServiceMock.Object);
|
||||
var service = new ProcessExecutionService(_logServiceMock.Object, _dataServiceMock.Object);
|
||||
|
||||
service.Execute(tool);
|
||||
await service.ExecuteAsync(tool);
|
||||
|
||||
// Opening a non-existent file should throw and be caught
|
||||
_logServiceMock.Verify(x => x.Error(It.Is<string>(s => s.Contains("FailTool") && s.Contains("失败"))), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Execute_Success_LogsInfo()
|
||||
public async Task Execute_Success_LogsInfo()
|
||||
{
|
||||
// Use a command that definitely exists on Windows
|
||||
var tool = new ToolItem
|
||||
{
|
||||
Name = "记事本",
|
||||
ExecutablePath = "notepad.exe",
|
||||
IsValid = true
|
||||
};
|
||||
var service = new ProcessExecutionService(_logServiceMock.Object);
|
||||
var service = new ProcessExecutionService(_logServiceMock.Object, _dataServiceMock.Object);
|
||||
|
||||
service.Execute(tool);
|
||||
await service.ExecuteAsync(tool);
|
||||
|
||||
_logServiceMock.Verify(x => x.Info(It.Is<string>(s => s.Contains("成功启动") && s.Contains("记事本"))), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_Group_BatchExecutesSubTools()
|
||||
{
|
||||
var sub1 = new ToolItem { Id = "s1", Name = "Sub1", ExecutablePath = "cmd.exe", IsValid = true };
|
||||
var sub2 = new ToolItem { Id = "s2", Name = "Sub2", ExecutablePath = "cmd.exe", IsValid = true };
|
||||
_config.Tools.Add(sub1);
|
||||
_config.Tools.Add(sub2);
|
||||
|
||||
var group = new ToolItem
|
||||
{
|
||||
Name = "MyGroup",
|
||||
IsGroup = true,
|
||||
SubToolIds = new List<string> { "s1", "s2" },
|
||||
IsValid = true
|
||||
};
|
||||
var service = new ProcessExecutionService(_logServiceMock.Object, _dataServiceMock.Object);
|
||||
|
||||
await service.ExecuteAsync(group);
|
||||
|
||||
_logServiceMock.Verify(x => x.Info(It.Is<string>(s => s.Contains("开始启动组合"))), Times.Once);
|
||||
_logServiceMock.Verify(x => x.Info(It.Is<string>(s => s.Contains("组合") && s.Contains("启动完成"))), Times.Once);
|
||||
_logServiceMock.Verify(x => x.Info(It.Is<string>(s => s.Contains("成功启动") && s.Contains("Sub1"))), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_Group_OrphanSubIds_Skipped()
|
||||
{
|
||||
var sub1 = new ToolItem { Id = "s1", Name = "Sub1", ExecutablePath = "cmd.exe", IsValid = true };
|
||||
_config.Tools.Add(sub1);
|
||||
|
||||
var group = new ToolItem
|
||||
{
|
||||
Name = "Group",
|
||||
IsGroup = true,
|
||||
SubToolIds = new List<string> { "s1", "missing_id" },
|
||||
IsValid = true
|
||||
};
|
||||
var service = new ProcessExecutionService(_logServiceMock.Object, _dataServiceMock.Object);
|
||||
|
||||
await service.ExecuteAsync(group);
|
||||
|
||||
_logServiceMock.Verify(x => x.Warning(It.Is<string>(s => s.Contains("找不到 ID") && s.Contains("missing_id"))), Times.Once);
|
||||
_logServiceMock.Verify(x => x.Info(It.Is<string>(s => s.Contains("成功启动") && s.Contains("Sub1"))), Times.Once);
|
||||
}
|
||||
}
|
||||
|
||||
97
PersonalToolBox.Tests/ViewModels/GroupEditViewModelTests.cs
Normal file
97
PersonalToolBox.Tests/ViewModels/GroupEditViewModelTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -165,7 +165,7 @@ public class MainViewModelTests
|
||||
|
||||
vm.ExecuteToolCommand.Execute(tool);
|
||||
|
||||
_processServiceMock.Verify(x => x.Execute(tool), Times.Once);
|
||||
_processServiceMock.Verify(x => x.ExecuteAsync(tool), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -173,7 +173,7 @@ public class MainViewModelTests
|
||||
{
|
||||
var vm = CreateViewModel();
|
||||
vm.ExecuteToolCommand.Execute(null);
|
||||
_processServiceMock.Verify(x => x.Execute(It.IsAny<ToolItem>()), Times.Never);
|
||||
_processServiceMock.Verify(x => x.ExecuteAsync(It.IsAny<ToolItem>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user