Phase 2-3: UI layout, theme switching, CRUD tools, process execution
- Phase 2: MainWindow 3-section layout (sidebar/content/log bar), Dark/Light theme with ThemeHelper, MainViewModel with ObservableProperty/RelayCommand, tool card filtering by search + category - Phase 3: ToolEditWindow for add/edit tools, ProcessExecutionService (Process.Start + error handling), double-click + right-click context menu (run/edit), path browse dialog - Bugfix: ContextMenu commands now use PlacementTarget.Tag binding (ContextMenu in separate visual tree) - Bugfix: StaticResource converters moved to XAML before DataTemplate to fix XamlParseException on tool card render - Bugfix: Pure filenames (no path separators) treated as PATH commands, not marked invalid - Bugfix: RefreshData preserves SelectedCategory; Load() catches all exceptions; Save() wrapped in try-catch; auto-scroll log to newest entry - Tests: xUnit project with 55 tests covering models, services, converters, and view models
This commit is contained in:
186
PersonalToolBox.Tests/ViewModels/MainViewModelTests.cs
Normal file
186
PersonalToolBox.Tests/ViewModels/MainViewModelTests.cs
Normal file
@@ -0,0 +1,186 @@
|
||||
using Moq;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PersonalToolBox.Models;
|
||||
using PersonalToolBox.Services;
|
||||
using PersonalToolBox.ViewModels;
|
||||
|
||||
namespace PersonalToolBox.Tests.ViewModels;
|
||||
|
||||
public class MainViewModelTests
|
||||
{
|
||||
private readonly Mock<ILogService> _logServiceMock = new();
|
||||
private readonly Mock<IDataService> _dataServiceMock = new();
|
||||
private readonly Mock<IProcessExecutionService> _processServiceMock = new();
|
||||
private readonly Mock<IServiceProvider> _serviceProviderMock = new();
|
||||
private readonly AppConfig _config;
|
||||
|
||||
public MainViewModelTests()
|
||||
{
|
||||
_config = new AppConfig
|
||||
{
|
||||
Theme = "Dark",
|
||||
Categories =
|
||||
[
|
||||
new() { Id = "1", Name = "开发工具" },
|
||||
new() { Id = "2", Name = "系统工具" }
|
||||
],
|
||||
Tools =
|
||||
[
|
||||
new() { Id = "t1", Name = "VS Code", CategoryId = "1", IsValid = true },
|
||||
new() { Id = "t2", Name = "Postman", CategoryId = "1", IsValid = true },
|
||||
new() { Id = "t3", Name = "任务管理器", CategoryId = "2", IsValid = true },
|
||||
new() { Id = "t4", Name = "失效工具", CategoryId = "1", ExecutablePath = @"C:\missing.exe", IsValid = false }
|
||||
]
|
||||
};
|
||||
|
||||
_dataServiceMock.Setup(d => d.Config).Returns(_config);
|
||||
}
|
||||
|
||||
private MainViewModel CreateViewModel() =>
|
||||
new(_logServiceMock.Object, _dataServiceMock.Object, _processServiceMock.Object, _serviceProviderMock.Object);
|
||||
|
||||
[Fact]
|
||||
public void Constructor_LoadsCategories_IncludingAll()
|
||||
{
|
||||
var vm = CreateViewModel();
|
||||
|
||||
Assert.Equal(3, vm.Categories.Count);
|
||||
Assert.Equal("全部", vm.Categories[0].Name);
|
||||
Assert.Equal("开发工具", vm.Categories[1].Name);
|
||||
Assert.Equal("系统工具", vm.Categories[2].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_LoadsAllTools()
|
||||
{
|
||||
var vm = CreateViewModel();
|
||||
Assert.Equal(4, vm.Tools.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_CategoryIsAll_ShowsAllTools()
|
||||
{
|
||||
var vm = CreateViewModel();
|
||||
Assert.Equal(4, vm.FilteredTools.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectCategory_FiltersByCategory()
|
||||
{
|
||||
var vm = CreateViewModel();
|
||||
vm.SelectedCategory = vm.Categories[1];
|
||||
Assert.Equal(3, vm.FilteredTools.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SearchText_FiltersByName()
|
||||
{
|
||||
var vm = CreateViewModel();
|
||||
vm.SearchText = "post";
|
||||
Assert.Single(vm.FilteredTools);
|
||||
Assert.Equal("Postman", vm.FilteredTools[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SearchText_CaseInsensitive()
|
||||
{
|
||||
var vm = CreateViewModel();
|
||||
vm.SearchText = "POSTMAN";
|
||||
Assert.Single(vm.FilteredTools);
|
||||
Assert.Equal("Postman", vm.FilteredTools[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SearchText_And_Category_FilterCombined()
|
||||
{
|
||||
var vm = CreateViewModel();
|
||||
vm.SelectedCategory = vm.Categories[1];
|
||||
vm.SearchText = "code";
|
||||
Assert.Single(vm.FilteredTools);
|
||||
Assert.Equal("VS Code", vm.FilteredTools[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SearchText_Empty_ShowsAllInCategory()
|
||||
{
|
||||
var vm = CreateViewModel();
|
||||
vm.SelectedCategory = vm.Categories[2];
|
||||
vm.SearchText = "";
|
||||
Assert.Single(vm.FilteredTools);
|
||||
Assert.Equal("任务管理器", vm.FilteredTools[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SearchText_NoMatch_ReturnsEmpty()
|
||||
{
|
||||
var vm = CreateViewModel();
|
||||
vm.SearchText = "nonexistent";
|
||||
Assert.Empty(vm.FilteredTools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidTools_StillAppearInFilteredResults()
|
||||
{
|
||||
var vm = CreateViewModel();
|
||||
vm.SelectedCategory = vm.Categories[1];
|
||||
var invalid = vm.FilteredTools.FirstOrDefault(t => t.Name == "失效工具");
|
||||
Assert.NotNull(invalid);
|
||||
Assert.False(invalid.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearLogsCommand_CallsLogServiceClear()
|
||||
{
|
||||
var vm = CreateViewModel();
|
||||
vm.ClearLogsCommand.Execute(null);
|
||||
_logServiceMock.Verify(x => x.Clear(), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToggleTheme_SwitchesDarkToLight()
|
||||
{
|
||||
var vm = CreateViewModel();
|
||||
Assert.Equal("Dark", vm.CurrentTheme);
|
||||
vm.ToggleThemeCommand.Execute(null);
|
||||
Assert.Equal("Light", vm.CurrentTheme);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToggleTheme_CallsSave()
|
||||
{
|
||||
var vm = CreateViewModel();
|
||||
vm.ToggleThemeCommand.Execute(null);
|
||||
_dataServiceMock.Verify(x => x.Save(), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExecuteTool_CallsProcessService()
|
||||
{
|
||||
var vm = CreateViewModel();
|
||||
var tool = vm.Tools[0];
|
||||
|
||||
vm.ExecuteToolCommand.Execute(tool);
|
||||
|
||||
_processServiceMock.Verify(x => x.Execute(tool), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExecuteTool_Null_DoesNotCallProcessService()
|
||||
{
|
||||
var vm = CreateViewModel();
|
||||
vm.ExecuteToolCommand.Execute(null);
|
||||
_processServiceMock.Verify(x => x.Execute(It.IsAny<ToolItem>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RefreshData_ReloadsFromConfig()
|
||||
{
|
||||
var vm = CreateViewModel();
|
||||
|
||||
_config.Tools.Add(new ToolItem { Name = "NewTool" });
|
||||
|
||||
vm.RefreshData();
|
||||
|
||||
Assert.Equal(5, vm.Tools.Count);
|
||||
}
|
||||
}
|
||||
151
PersonalToolBox.Tests/ViewModels/ToolEditViewModelTests.cs
Normal file
151
PersonalToolBox.Tests/ViewModels/ToolEditViewModelTests.cs
Normal file
@@ -0,0 +1,151 @@
|
||||
using Moq;
|
||||
using PersonalToolBox.Models;
|
||||
using PersonalToolBox.Services;
|
||||
using PersonalToolBox.ViewModels;
|
||||
|
||||
namespace PersonalToolBox.Tests.ViewModels;
|
||||
|
||||
public class ToolEditViewModelTests
|
||||
{
|
||||
private readonly Mock<IDataService> _dataServiceMock = new();
|
||||
private readonly Mock<ILogService> _logServiceMock = new();
|
||||
private readonly AppConfig _config;
|
||||
|
||||
public ToolEditViewModelTests()
|
||||
{
|
||||
_config = new AppConfig
|
||||
{
|
||||
Categories =
|
||||
[
|
||||
new() { Id = "1", Name = "开发工具" }
|
||||
]
|
||||
};
|
||||
_dataServiceMock.Setup(d => d.Config).Returns(_config);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_AddMode_SetsTitle()
|
||||
{
|
||||
var vm = new ToolEditViewModel(_dataServiceMock.Object, _logServiceMock.Object);
|
||||
|
||||
Assert.Equal("添加工具", vm.WindowTitle);
|
||||
Assert.Empty(vm.Name);
|
||||
Assert.Empty(vm.ExecutablePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_EditMode_SetsTitleAndFields()
|
||||
{
|
||||
var tool = new ToolItem
|
||||
{
|
||||
Name = "MyTool",
|
||||
ExecutablePath = @"C:\test.exe",
|
||||
Arguments = "/arg",
|
||||
HotKey = "Ctrl+T",
|
||||
CategoryId = "1"
|
||||
};
|
||||
|
||||
var vm = new ToolEditViewModel(_dataServiceMock.Object, _logServiceMock.Object, tool);
|
||||
|
||||
Assert.Equal("编辑工具", vm.WindowTitle);
|
||||
Assert.Equal("MyTool", vm.Name);
|
||||
Assert.Equal(@"C:\test.exe", vm.ExecutablePath);
|
||||
Assert.Equal("/arg", vm.Arguments);
|
||||
Assert.Equal("Ctrl+T", vm.HotKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_EmptyName_LogsWarning()
|
||||
{
|
||||
var vm = new ToolEditViewModel(_dataServiceMock.Object, _logServiceMock.Object)
|
||||
{
|
||||
ExecutablePath = @"C:\test.exe"
|
||||
};
|
||||
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
_logServiceMock.Verify(x => x.Warning(It.Is<string>(s => s.Contains("名称不能为空"))), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_EmptyPath_LogsWarning()
|
||||
{
|
||||
var vm = new ToolEditViewModel(_dataServiceMock.Object, _logServiceMock.Object)
|
||||
{
|
||||
Name = "Test"
|
||||
};
|
||||
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
_logServiceMock.Verify(x => x.Warning(It.Is<string>(s => s.Contains("路径不能为空"))), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_AddMode_AddsToolAndCloses()
|
||||
{
|
||||
var vm = new ToolEditViewModel(_dataServiceMock.Object, _logServiceMock.Object)
|
||||
{
|
||||
Name = "NewTool",
|
||||
ExecutablePath = @"C:\test.exe"
|
||||
};
|
||||
bool? closeResult = null;
|
||||
vm.CloseAction = (r) => closeResult = r;
|
||||
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.True(vm.Saved);
|
||||
Assert.True(closeResult);
|
||||
Assert.Single(_config.Tools);
|
||||
Assert.Equal("NewTool", _config.Tools[0].Name);
|
||||
_dataServiceMock.Verify(x => x.Save(), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_EditMode_UpdatesToolAndCloses()
|
||||
{
|
||||
var tool = new ToolItem
|
||||
{
|
||||
Name = "OldName",
|
||||
ExecutablePath = @"C:\old.exe"
|
||||
};
|
||||
|
||||
var vm = new ToolEditViewModel(_dataServiceMock.Object, _logServiceMock.Object, tool)
|
||||
{
|
||||
Name = "NewName",
|
||||
ExecutablePath = @"C:\new.exe"
|
||||
};
|
||||
bool? closeResult = null;
|
||||
vm.CloseAction = (r) => closeResult = r;
|
||||
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.True(vm.Saved);
|
||||
Assert.True(closeResult);
|
||||
Assert.Equal("NewName", tool.Name);
|
||||
Assert.Equal(@"C:\new.exe", tool.ExecutablePath);
|
||||
_dataServiceMock.Verify(x => x.Save(), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cancel_ClosesWithoutSaving()
|
||||
{
|
||||
var vm = new ToolEditViewModel(_dataServiceMock.Object, _logServiceMock.Object);
|
||||
bool? closeResult = null;
|
||||
vm.CloseAction = (r) => closeResult = r;
|
||||
|
||||
vm.CancelCommand.Execute(null);
|
||||
|
||||
Assert.False(vm.Saved);
|
||||
Assert.False(closeResult);
|
||||
_dataServiceMock.Verify(x => x.Save(), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_LoadsCategories()
|
||||
{
|
||||
var vm = new ToolEditViewModel(_dataServiceMock.Object, _logServiceMock.Object);
|
||||
|
||||
Assert.Single(vm.Categories);
|
||||
Assert.Equal("开发工具", vm.Categories[0].Name);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user