diff --git a/PersonalToolBox.Tests/Models/ModelSerializationTests.cs b/PersonalToolBox.Tests/Models/ModelSerializationTests.cs new file mode 100644 index 0000000..bf7fb22 --- /dev/null +++ b/PersonalToolBox.Tests/Models/ModelSerializationTests.cs @@ -0,0 +1,118 @@ +using System.Text.Json; +using PersonalToolBox.Models; + +namespace PersonalToolBox.Tests.Models; + +public class ModelSerializationTests +{ + private static readonly JsonSerializerOptions Options = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true + }; + + [Fact] + public void AppConfig_Serialize_ProducesValidJson() + { + var config = new AppConfig + { + Theme = "Dark", + AutoStart = true, + Categories = + [ + new() { Id = "1", Name = "开发工具" }, + new() { Id = "2", Name = "常用脚本" } + ], + Tools = + [ + new() + { + Id = "tool-1", + Name = "Postman", + IconCode = "f0c2", + ExecutablePath = @"D:\tools\postman.exe", + Arguments = "", + CategoryId = "1", + HotKey = "Ctrl+Alt+P" + } + ] + }; + + var json = JsonSerializer.Serialize(config, Options); + var deserialized = JsonSerializer.Deserialize(json, Options); + + Assert.NotNull(deserialized); + Assert.Equal("Dark", deserialized.Theme); + Assert.True(deserialized.AutoStart); + Assert.Equal(2, deserialized.Categories.Count); + Assert.Single(deserialized.Tools); + } + + [Fact] + public void ToolItem_IsValid_NotIncludedInJson() + { + var tool = new ToolItem + { + Name = "TestTool", + ExecutablePath = @"C:\nonexistent.exe", + IsValid = false + }; + + var json = JsonSerializer.Serialize(tool, Options); + + Assert.DoesNotContain("IsValid", json); + } + + [Fact] + public void ToolItem_DefaultValues_AreCorrect() + { + var tool = new ToolItem(); + + Assert.NotEmpty(tool.Id); + Assert.Empty(tool.Name); + Assert.True(tool.IsValid); + } + + [Fact] + public void Category_DefaultId_IsNotEmpty() + { + var cat = new Category(); + Assert.NotEmpty(cat.Id); + } + + [Fact] + public void AppConfig_DefaultValues_AreCorrect() + { + var config = new AppConfig(); + + Assert.Equal("Dark", config.Theme); + Assert.False(config.AutoStart); + Assert.NotNull(config.Categories); + Assert.NotNull(config.Tools); + Assert.Empty(config.Categories); + Assert.Empty(config.Tools); + } + + [Fact] + public void Deserialize_EmptyJson_ProducesValidConfig() + { + var json = "{}"; + var config = JsonSerializer.Deserialize(json, Options); + + Assert.NotNull(config); + Assert.Equal("Dark", config.Theme); + Assert.False(config.AutoStart); + Assert.Empty(config.Categories); + Assert.Empty(config.Tools); + } + + [Fact] + public void Deserialize_WithUnknownProperty_IgnoresIt() + { + var json = """{"unknownField": 123, "Theme": "Light"}"""; + var config = JsonSerializer.Deserialize(json, Options); + + Assert.NotNull(config); + Assert.Equal("Light", config.Theme); + } +} diff --git a/PersonalToolBox.Tests/PersonalToolBox.Tests.csproj b/PersonalToolBox.Tests/PersonalToolBox.Tests.csproj new file mode 100644 index 0000000..b1060b5 --- /dev/null +++ b/PersonalToolBox.Tests/PersonalToolBox.Tests.csproj @@ -0,0 +1,29 @@ + + + + net8.0-windows + enable + enable + true + + false + true + + + + + + + + + + + + + + + + + + + diff --git a/PersonalToolBox.Tests/Services/JsonDataServiceTests.cs b/PersonalToolBox.Tests/Services/JsonDataServiceTests.cs new file mode 100644 index 0000000..e57926b --- /dev/null +++ b/PersonalToolBox.Tests/Services/JsonDataServiceTests.cs @@ -0,0 +1,194 @@ +using System.IO; +using Moq; +using PersonalToolBox.Models; +using PersonalToolBox.Services; + +namespace PersonalToolBox.Tests.Services; + +public class JsonDataServiceTests +{ + private readonly Mock _logServiceMock = new(); + private readonly string _testDir; + + public JsonDataServiceTests() + { + _testDir = Path.Combine(Path.GetTempPath(), $"toolbox_test_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_testDir); + } + + [Fact] + public void Load_FileNotExists_CreatesDefaultConfig() + { + // Use a path that doesn't exist + var configPath = Path.Combine(_testDir, "nonexistent.json"); + var service = CreateService(configPath); + + service.Load(); + + Assert.NotNull(service.Config); + Assert.Equal("Dark", service.Config.Theme); + Assert.Empty(service.Config.Tools); + + // Verify warning was logged + VerifyLog(LogLevel.Info, "默认配置"); + } + + [Fact] + public void Save_ThenLoad_PreservesData() + { + var configPath = Path.Combine(_testDir, "config.json"); + var service = CreateService(configPath); + + service.Config.Theme = "Light"; + service.Config.Categories.Add(new Category { Id = "1", Name = "dev" }); + service.Config.Tools.Add(new ToolItem + { + Name = "TestTool", + ExecutablePath = Path.Combine(_testDir, "test.exe") + }); + + service.Save(); + + // Load into a new service + var service2 = CreateService(configPath); + service2.Load(); + + Assert.Equal("Light", service2.Config.Theme); + Assert.Single(service2.Config.Categories); + Assert.Equal("dev", service2.Config.Categories[0].Name); + Assert.Single(service2.Config.Tools); + Assert.Equal("TestTool", service2.Config.Tools[0].Name); + } + + [Fact] + public void Load_InvalidPath_ToolSetToInvalid() + { + var configPath = Path.Combine(_testDir, "config.json"); + var service = CreateService(configPath); + + service.Config.Tools.Add(new ToolItem + { + Name = "MissingTool", + ExecutablePath = @"C:\definitely\does\not\exist.exe" + }); + + service.Save(); + + var service2 = CreateService(configPath); + service2.Load(); + + var tool = service2.Config.Tools[0]; + Assert.False(tool.IsValid); + VerifyLog(LogLevel.Warning, "路径失效"); + } + + [Fact] + public void Load_ValidPath_ToolRemainsValid() + { + var configPath = Path.Combine(_testDir, "config.json"); + var service = CreateService(configPath); + + // Create a real temp file + var tempFile = Path.Combine(_testDir, "real_tool.exe"); + File.WriteAllText(tempFile, "dummy"); + + service.Config.Tools.Add(new ToolItem + { + Name = "RealTool", + ExecutablePath = tempFile + }); + + service.Save(); + + var service2 = CreateService(configPath); + service2.Load(); + + var tool = service2.Config.Tools[0]; + Assert.True(tool.IsValid); + } + + [Fact] + public void Load_UrlPath_AlwaysValid() + { + var configPath = Path.Combine(_testDir, "config.json"); + var service = CreateService(configPath); + + service.Config.Tools.Add(new ToolItem + { + Name = "WebTool", + ExecutablePath = "https://example.com/tool" + }); + + service.Save(); + + var service2 = CreateService(configPath); + service2.Load(); + + var tool = service2.Config.Tools[0]; + Assert.True(tool.IsValid); + } + + [Fact] + public void Load_EmptyExecutablePath_SkipsValidation() + { + var configPath = Path.Combine(_testDir, "config.json"); + var service = CreateService(configPath); + + service.Config.Tools.Add(new ToolItem + { + Name = "NoPathTool", + ExecutablePath = "" + }); + + service.Save(); + + var service2 = CreateService(configPath); + service2.Load(); + + var tool = service2.Config.Tools[0]; + Assert.True(tool.IsValid); + } + + [Fact] + public void Load_CorruptedJson_LogsErrorAndUsesDefaults() + { + var configPath = Path.Combine(_testDir, "config.json"); + File.WriteAllText(configPath, "this is not json {{{"); + + var service = CreateService(configPath); + service.Load(); + + Assert.NotNull(service.Config); + Assert.Equal("Dark", service.Config.Theme); + VerifyLog(LogLevel.Error, "解析失败"); + } + + // ───────────────────────────── helpers ───────────────────────────── + + private JsonDataService CreateService(string filePath) + { + var service = new JsonDataService(_logServiceMock.Object); + // Replace the file path via reflection + typeof(JsonDataService) + .GetField("_filePath", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)? + .SetValue(service, filePath); + return service; + } + + private void VerifyLog(LogLevel level, string fragment) + { + // 根据日志级别验证对应方法被调用 + switch (level) + { + case LogLevel.Info: + _logServiceMock.Verify(x => x.Info(It.Is(s => s.Contains(fragment))), Times.AtLeastOnce); + break; + case LogLevel.Warning: + _logServiceMock.Verify(x => x.Warning(It.Is(s => s.Contains(fragment))), Times.AtLeastOnce); + break; + case LogLevel.Error: + _logServiceMock.Verify(x => x.Error(It.Is(s => s.Contains(fragment))), Times.AtLeastOnce); + break; + } + } +} diff --git a/PersonalToolBox.Tests/Services/LogServiceTests.cs b/PersonalToolBox.Tests/Services/LogServiceTests.cs new file mode 100644 index 0000000..09ce2ac --- /dev/null +++ b/PersonalToolBox.Tests/Services/LogServiceTests.cs @@ -0,0 +1,96 @@ +using PersonalToolBox.Models; +using PersonalToolBox.Services; + +namespace PersonalToolBox.Tests.Services; + +public class LogServiceTests +{ + private readonly LogService _logService = new(); + + [Fact] + public void Info_AddsLogEntry() + { + _logService.Info("test message"); + + Assert.Single(_logService.Logs); + var entry = _logService.Logs[0]; + Assert.Equal(LogLevel.Info, entry.Level); + Assert.Equal("test message", entry.Content); + Assert.True((DateTime.Now - entry.Timestamp).TotalSeconds < 2); + } + + [Fact] + public void Warning_AddsLogEntry_WithWarningLevel() + { + _logService.Warning("disk full"); + + Assert.Single(_logService.Logs); + Assert.Equal(LogLevel.Warning, _logService.Logs[0].Level); + Assert.Equal("disk full", _logService.Logs[0].Content); + } + + [Fact] + public void Error_AddsLogEntry_WithErrorLevel() + { + _logService.Error("crash detected"); + + Assert.Single(_logService.Logs); + Assert.Equal(LogLevel.Error, _logService.Logs[0].Level); + Assert.Equal("crash detected", _logService.Logs[0].Content); + } + + [Fact] + public void MultipleLogs_AreAddedInOrder() + { + _logService.Info("first"); + _logService.Warning("second"); + _logService.Error("third"); + + Assert.Equal(3, _logService.Logs.Count); + Assert.Equal(LogLevel.Info, _logService.Logs[0].Level); + Assert.Equal(LogLevel.Warning, _logService.Logs[1].Level); + Assert.Equal(LogLevel.Error, _logService.Logs[2].Level); + } + + [Fact] + public void Clear_RemovesAllEntries() + { + _logService.Info("msg"); + _logService.Info("msg"); + + Assert.Equal(2, _logService.Logs.Count); + + _logService.Clear(); + + Assert.Empty(_logService.Logs); + } + + [Fact] + public void LogEntry_ToString_FormatsCorrectly() + { + var entry = new LogEntry + { + Timestamp = new DateTime(2026, 5, 9, 20, 0, 0), + Level = LogLevel.Warning, + Content = "path not found" + }; + + var str = entry.ToString(); + + Assert.Contains("[2026-05-09 20:00:00]", str); + Assert.Contains("[警告]", str); + Assert.Contains("path not found", str); + } + + [Fact] + public void LogEntry_LevelText_ProducesChineseLabels() + { + var info = new LogEntry { Level = LogLevel.Info }; + var warn = new LogEntry { Level = LogLevel.Warning }; + var err = new LogEntry { Level = LogLevel.Error }; + + Assert.Equal("[信息]", info.LevelText); + Assert.Equal("[警告]", warn.LevelText); + Assert.Equal("[错误]", err.LevelText); + } +} diff --git a/PersonalToolBox.Tests/Services/ProcessExecutionServiceTests.cs b/PersonalToolBox.Tests/Services/ProcessExecutionServiceTests.cs new file mode 100644 index 0000000..58cb9f0 --- /dev/null +++ b/PersonalToolBox.Tests/Services/ProcessExecutionServiceTests.cs @@ -0,0 +1,70 @@ +using Moq; +using PersonalToolBox.Models; +using PersonalToolBox.Services; + +namespace PersonalToolBox.Tests.Services; + +public class ProcessExecutionServiceTests +{ + private readonly Mock _logServiceMock = new(); + + [Fact] + public void Execute_NullTool_LogsError() + { + var service = new ProcessExecutionService(_logServiceMock.Object); + + service.Execute(null!); + + _logServiceMock.Verify(x => x.Error(It.Is(s => s.Contains("空工具项"))), Times.Once); + } + + [Fact] + public void Execute_InvalidTool_LogsWarning() + { + var tool = new ToolItem + { + Name = "BadTool", + ExecutablePath = @"C:\nonexistent.exe", + IsValid = false + }; + var service = new ProcessExecutionService(_logServiceMock.Object); + + service.Execute(tool); + + _logServiceMock.Verify(x => x.Warning(It.Is(s => s.Contains("无法运行") && s.Contains("BadTool"))), Times.Once); + } + + [Fact] + public void Execute_ProcessFails_LogsError() + { + var tool = new ToolItem + { + Name = "FailTool", + ExecutablePath = @"Z:\impossible_path\not_really.exe", + IsValid = true + }; + var service = new ProcessExecutionService(_logServiceMock.Object); + + service.Execute(tool); + + // Opening a non-existent file should throw and be caught + _logServiceMock.Verify(x => x.Error(It.Is(s => s.Contains("FailTool") && s.Contains("失败"))), Times.Once); + } + + [Fact] + public void 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); + + service.Execute(tool); + + _logServiceMock.Verify(x => x.Info(It.Is(s => s.Contains("成功启动") && s.Contains("记事本"))), Times.Once); + } +} diff --git a/PersonalToolBox.Tests/ViewModels/MainViewModelTests.cs b/PersonalToolBox.Tests/ViewModels/MainViewModelTests.cs new file mode 100644 index 0000000..dc2a060 --- /dev/null +++ b/PersonalToolBox.Tests/ViewModels/MainViewModelTests.cs @@ -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 _logServiceMock = new(); + private readonly Mock _dataServiceMock = new(); + private readonly Mock _processServiceMock = new(); + private readonly Mock _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()), 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); + } +} diff --git a/PersonalToolBox.Tests/ViewModels/ToolEditViewModelTests.cs b/PersonalToolBox.Tests/ViewModels/ToolEditViewModelTests.cs new file mode 100644 index 0000000..31b1d95 --- /dev/null +++ b/PersonalToolBox.Tests/ViewModels/ToolEditViewModelTests.cs @@ -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 _dataServiceMock = new(); + private readonly Mock _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(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(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); + } +} diff --git a/PersonalToolBox.Tests/Views/ConverterTests.cs b/PersonalToolBox.Tests/Views/ConverterTests.cs new file mode 100644 index 0000000..d2d25be --- /dev/null +++ b/PersonalToolBox.Tests/Views/ConverterTests.cs @@ -0,0 +1,67 @@ +using System.Globalization; +using PersonalToolBox.Views; + +namespace PersonalToolBox.Tests.Views; + +public class ConverterTests +{ + [Fact] + public void BoolToOpacityConverter_True_ReturnsOne() + { + var converter = new BoolToOpacityConverter(); + + var result = converter.Convert(true, typeof(double), null, CultureInfo.InvariantCulture); + + Assert.Equal(1.0, result); + } + + [Fact] + public void BoolToOpacityConverter_False_ReturnsZeroPointFour() + { + var converter = new BoolToOpacityConverter(); + + var result = converter.Convert(false, typeof(double), null, CultureInfo.InvariantCulture); + + Assert.Equal(0.4, result); + } + + [Fact] + public void BoolToOpacityConverter_NonBool_ReturnsZeroPointFour() + { + var converter = new BoolToOpacityConverter(); + + var result = converter.Convert("not a bool", typeof(double), null, CultureInfo.InvariantCulture); + + Assert.Equal(0.4, result); + } + + [Fact] + public void FirstCharConverter_NormalString_ReturnsFirstChar() + { + var converter = new FirstCharConverter(); + + var result = converter.Convert("Postman", typeof(string), null, CultureInfo.InvariantCulture); + + Assert.Equal("P", result); + } + + [Fact] + public void FirstCharConverter_EmptyString_ReturnsQuestionMark() + { + var converter = new FirstCharConverter(); + + var result = converter.Convert("", typeof(string), null, CultureInfo.InvariantCulture); + + Assert.Equal("?", result); + } + + [Fact] + public void FirstCharConverter_Null_ReturnsQuestionMark() + { + var converter = new FirstCharConverter(); + + var result = converter.Convert(null!, typeof(string), null, CultureInfo.InvariantCulture); + + Assert.Equal("?", result); + } +} diff --git a/PersonalToolBox/App.xaml b/PersonalToolBox/App.xaml index 1fd3954..2be9e12 100644 --- a/PersonalToolBox/App.xaml +++ b/PersonalToolBox/App.xaml @@ -3,6 +3,10 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="clr-namespace:PersonalToolBox"> - + + + + + diff --git a/PersonalToolBox/App.xaml.cs b/PersonalToolBox/App.xaml.cs index 0236c89..08f2458 100644 --- a/PersonalToolBox/App.xaml.cs +++ b/PersonalToolBox/App.xaml.cs @@ -1,5 +1,6 @@ -using Microsoft.Extensions.DependencyInjection; +using System.IO; using System.Windows; +using Microsoft.Extensions.DependencyInjection; using PersonalToolBox.Services; using PersonalToolBox.Views; @@ -12,34 +13,81 @@ public partial class App : Application { public static IServiceProvider Services { get; private set; } = null!; + private static readonly string CrashLogPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "PersonalToolBox", "crash.log"); + + public App() + { + // 监听未处理的异常,写入文件日志 + DispatcherUnhandledException += (s, e) => + { + WriteCrashLog($"UI线程异常: {e.Exception}"); + e.Handled = true; + MessageBox.Show($"发生未处理异常:\n{e.Exception.Message}\n\n详情已写入:\n{CrashLogPath}", + "错误", MessageBoxButton.OK, MessageBoxImage.Error); + }; + + AppDomain.CurrentDomain.UnhandledException += (s, e) => + { + WriteCrashLog($"未处理异常: {e.ExceptionObject}"); + }; + } + protected override void OnStartup(StartupEventArgs e) { - base.OnStartup(e); + try + { + base.OnStartup(e); - var services = new ServiceCollection(); - ConfigureServices(services); - Services = services.BuildServiceProvider(); + var services = new ServiceCollection(); + ConfigureServices(services); + Services = services.BuildServiceProvider(); - // 启动时加载配置文件(含路径验证与容错) - var dataService = Services.GetRequiredService(); - dataService.Load(); + // 启动时加载配置文件(含路径验证与容错) + var dataService = Services.GetRequiredService(); + dataService.Load(); - var mainWindow = Services.GetRequiredService(); - mainWindow.Show(); + var mainWindow = Services.GetRequiredService(); + mainWindow.Show(); + } + catch (Exception ex) + { + WriteCrashLog($"启动失败: {ex}"); + MessageBox.Show($"启动失败:\n{ex.Message}\n\n详情已写入:\n{CrashLogPath}", + "启动错误", MessageBoxButton.OK, MessageBoxImage.Error); + Shutdown(); + } } /// - /// 注册所有服务到 DI 容器(单例模式) + /// 注册所有服务到 DI 容器 /// private static void ConfigureServices(IServiceCollection services) { - // 日志服务 services.AddSingleton(); - - // 数据持久化服务 services.AddSingleton(); - - // 主窗口 + services.AddSingleton(); + services.AddSingleton(); + services.AddTransient(); services.AddSingleton(); } + + /// + /// 将崩溃信息写入文件日志 + /// + public static void WriteCrashLog(string message) + { + try + { + var dir = Path.GetDirectoryName(CrashLogPath); + if (dir != null) Directory.CreateDirectory(dir); + File.AppendAllText(CrashLogPath, + $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {message}{Environment.NewLine}{Environment.NewLine}"); + } + catch + { + // 无法写入日志时静默忽略 + } + } } diff --git a/PersonalToolBox/Helpers/ThemeHelper.cs b/PersonalToolBox/Helpers/ThemeHelper.cs new file mode 100644 index 0000000..ef70965 --- /dev/null +++ b/PersonalToolBox/Helpers/ThemeHelper.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.ObjectModel; +using System.Windows; + +namespace PersonalToolBox.Helpers; + +/// +/// 主题切换辅助类,动态替换 Application 的 ResourceDictionary +/// +public static class ThemeHelper +{ + private const string ThemePrefix = "Themes/"; + private const string DarkThemePath = "Themes/DarkTheme.xaml"; + private const string LightThemePath = "Themes/LightTheme.xaml"; + + /// + /// 切换主题(Dark / Light) + /// + public static void ApplyTheme(string theme) + { + var app = Application.Current; + if (app == null) return; + + // 移除旧的主题资源字典 + var oldDict = FindThemeDictionary(app.Resources.MergedDictionaries); + if (oldDict != null) + app.Resources.MergedDictionaries.Remove(oldDict); + + // 加载新的主题资源字典 + var path = theme switch + { + "Light" => LightThemePath, + _ => DarkThemePath + }; + + var newDict = new ResourceDictionary + { + Source = new Uri(path, UriKind.Relative) + }; + app.Resources.MergedDictionaries.Add(newDict); + } + + /// + /// 在当前合并字典中查找主题相关的 ResourceDictionary + /// + private static ResourceDictionary? FindThemeDictionary(Collection dictionaries) + { + foreach (var dict in dictionaries) + { + if (dict.Source != null && dict.Source.OriginalString.StartsWith(ThemePrefix)) + return dict; + } + return null; + } +} diff --git a/PersonalToolBox/Services/IProcessExecutionService.cs b/PersonalToolBox/Services/IProcessExecutionService.cs new file mode 100644 index 0000000..7585131 --- /dev/null +++ b/PersonalToolBox/Services/IProcessExecutionService.cs @@ -0,0 +1,15 @@ +using PersonalToolBox.Models; + +namespace PersonalToolBox.Services; + +/// +/// 进程执行服务接口,负责启动外部工具进程 +/// +public interface IProcessExecutionService +{ + /// + /// 执行指定的工具项 + /// + /// 要执行的工具项 + void Execute(ToolItem tool); +} diff --git a/PersonalToolBox/Services/JsonDataService.cs b/PersonalToolBox/Services/JsonDataService.cs index a924772..b1c6756 100644 --- a/PersonalToolBox/Services/JsonDataService.cs +++ b/PersonalToolBox/Services/JsonDataService.cs @@ -65,11 +65,22 @@ public class JsonDataService : IDataService continue; } + // 纯文件名(无路径分隔符)可能位于系统 PATH 中,不标记为失效 + if (!tool.ExecutablePath.Contains('\\') && !tool.ExecutablePath.Contains('/')) + { + tool.IsValid = true; + continue; + } + if (!File.Exists(tool.ExecutablePath)) { tool.IsValid = false; _logService.Warning($"工具 \"{tool.Name}\" 路径失效,找不到文件: {tool.ExecutablePath}"); } + else + { + tool.IsValid = true; + } } _logService.Info($"配置加载完成: {Config.Categories.Count} 个分类, {Config.Tools.Count} 个工具"); @@ -79,6 +90,11 @@ public class JsonDataService : IDataService _logService.Error($"配置文件 JSON 解析失败: {ex.Message}"); Config = new AppConfig(); } + catch (Exception ex) + { + _logService.Error($"配置文件加载失败: {ex.Message}"); + Config = new AppConfig(); + } } public void Save() diff --git a/PersonalToolBox/Services/ProcessExecutionService.cs b/PersonalToolBox/Services/ProcessExecutionService.cs new file mode 100644 index 0000000..0156ecb --- /dev/null +++ b/PersonalToolBox/Services/ProcessExecutionService.cs @@ -0,0 +1,49 @@ +using System.Diagnostics; +using PersonalToolBox.Models; + +namespace PersonalToolBox.Services; + +/// +/// 进程执行服务,负责启动外部工具进程并处理异常 +/// +public class ProcessExecutionService : IProcessExecutionService +{ + private readonly ILogService _logService; + + public ProcessExecutionService(ILogService logService) + { + _logService = logService; + } + + public void Execute(ToolItem tool) + { + if (tool == null) + { + _logService.Error("尝试执行空工具项"); + return; + } + + if (!tool.IsValid) + { + _logService.Warning($"无法运行工具 \"{tool.Name}\",路径失效: {tool.ExecutablePath}"); + return; + } + + try + { + var startInfo = new ProcessStartInfo + { + FileName = tool.ExecutablePath, + Arguments = tool.Arguments ?? string.Empty, + UseShellExecute = true + }; + + Process.Start(startInfo); + _logService.Info($"成功启动: {tool.Name}"); + } + catch (Exception ex) + { + _logService.Error($"启动工具 \"{tool.Name}\" 失败: {ex.Message}"); + } + } +} diff --git a/PersonalToolBox/Themes/DarkTheme.xaml b/PersonalToolBox/Themes/DarkTheme.xaml new file mode 100644 index 0000000..c9e5587 --- /dev/null +++ b/PersonalToolBox/Themes/DarkTheme.xaml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + diff --git a/PersonalToolBox/Themes/LightTheme.xaml b/PersonalToolBox/Themes/LightTheme.xaml new file mode 100644 index 0000000..fd6d24e --- /dev/null +++ b/PersonalToolBox/Themes/LightTheme.xaml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + diff --git a/PersonalToolBox/ViewModels/MainViewModel.cs b/PersonalToolBox/ViewModels/MainViewModel.cs new file mode 100644 index 0000000..7539af3 --- /dev/null +++ b/PersonalToolBox/ViewModels/MainViewModel.cs @@ -0,0 +1,178 @@ +using System.Collections.ObjectModel; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.DependencyInjection; +using PersonalToolBox.Helpers; +using PersonalToolBox.Models; +using PersonalToolBox.Services; +using PersonalToolBox.Views; + +namespace PersonalToolBox.ViewModels; + +/// +/// 主窗口 ViewModel,管理 UI 状态、数据绑定和用户交互逻辑 +/// +public partial class MainViewModel : ObservableObject +{ + private readonly ILogService _logService; + private readonly IDataService _dataService; + private readonly IProcessExecutionService _processService; + private readonly IServiceProvider _serviceProvider; + + /// + /// 全部分类的虚拟对象 + /// + private static readonly Category AllCategory = new() { Id = "", Name = "全部" }; + + public MainViewModel( + ILogService logService, + IDataService dataService, + IProcessExecutionService processService, + IServiceProvider serviceProvider) + { + _logService = logService; + _dataService = dataService; + _processService = processService; + _serviceProvider = serviceProvider; + + LoadData(); + } + + // ───────────────────────────── 可观察属性 ───────────────────────────── + + [ObservableProperty] + private string _searchText = string.Empty; + + [ObservableProperty] + private Category? _selectedCategory; + + public ObservableCollection Logs => _logService.Logs; + + public ObservableCollection Categories { get; } = new(); + + public ObservableCollection Tools { get; } = new(); + + [ObservableProperty] + private ObservableCollection _filteredTools = new(); + + [ObservableProperty] + private string _currentTheme = "Dark"; + + // ───────────────────────────── 命令 ───────────────────────────── + + [RelayCommand] + private void ClearLogs() => _logService.Clear(); + + [RelayCommand] + private void ToggleTheme() + { + CurrentTheme = CurrentTheme == "Dark" ? "Light" : "Dark"; + ThemeHelper.ApplyTheme(CurrentTheme); + _dataService.Config.Theme = CurrentTheme; + _dataService.Save(); + _logService.Info($"已切换到{(CurrentTheme == "Dark" ? "暗黑" : "明亮")}主题"); + } + + /// + /// 打开添加工具弹窗 + /// + [RelayCommand] + private void AddTool() + { + var vm = _serviceProvider.GetRequiredService(); + var window = new ToolEditWindow(vm); + window.ShowDialog(); + + if (vm.Saved) + { + RefreshData(); + } + } + + /// + /// 打开编辑工具弹窗 + /// + [RelayCommand] + private void EditTool(ToolItem tool) + { + if (tool == null) return; + + // 创建编辑 ViewModel(需要新建实例,DI 容器无法区分参数) + var dataService = _serviceProvider.GetRequiredService(); + var logService = _serviceProvider.GetRequiredService(); + var editVm = new ToolEditViewModel(dataService, logService, tool); + var window = new ToolEditWindow(editVm); + window.ShowDialog(); + + if (editVm.Saved) + { + RefreshData(); + } + } + + /// + /// 执行工具(双击卡片或右键菜单) + /// + [RelayCommand] + private void ExecuteTool(ToolItem? tool) + { + if (tool == null) return; + _processService.Execute(tool); + } + + // ───────────────────────────── 数据刷新 ───────────────────────────── + + /// + /// 从配置重新加载数据并刷新 UI + /// + public void RefreshData() + { + var previousCategoryId = SelectedCategory?.Id; + + Categories.Clear(); + Categories.Add(AllCategory); + + foreach (var cat in _dataService.Config.Categories) + Categories.Add(cat); + + Tools.Clear(); + foreach (var tool in _dataService.Config.Tools) + Tools.Add(tool); + + // 恢复之前选中的分类(若仍存在),否则选中"全部" + SelectedCategory = Categories.FirstOrDefault(c => c.Id == previousCategoryId) ?? AllCategory; + ApplyFilter(); + } + + // ───────────────────────────── 初始化 ───────────────────────────── + + private void LoadData() + { + RefreshData(); + + SelectedCategory = AllCategory; + CurrentTheme = _dataService.Config.Theme; + ThemeHelper.ApplyTheme(CurrentTheme); + } + + // ───────────────────────────── 过滤逻辑 ───────────────────────────── + + partial void OnSearchTextChanged(string value) => ApplyFilter(); + + partial void OnSelectedCategoryChanged(Category? value) => ApplyFilter(); + + private void ApplyFilter() + { + var filtered = Tools.AsEnumerable(); + + if (SelectedCategory != null && !string.IsNullOrEmpty(SelectedCategory.Id)) + filtered = filtered.Where(t => t.CategoryId == SelectedCategory.Id); + + if (!string.IsNullOrWhiteSpace(SearchText)) + filtered = filtered.Where(t => + t.Name.Contains(SearchText, System.StringComparison.OrdinalIgnoreCase)); + + FilteredTools = new ObservableCollection(filtered); + } +} diff --git a/PersonalToolBox/ViewModels/ToolEditViewModel.cs b/PersonalToolBox/ViewModels/ToolEditViewModel.cs new file mode 100644 index 0000000..86c124a --- /dev/null +++ b/PersonalToolBox/ViewModels/ToolEditViewModel.cs @@ -0,0 +1,191 @@ +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Win32; +using PersonalToolBox.Models; +using PersonalToolBox.Services; + +namespace PersonalToolBox.ViewModels; + +/// +/// 工具编辑窗口 ViewModel,管理添加/编辑工具的交互逻辑 +/// +public partial class ToolEditViewModel : ObservableObject +{ + private readonly IDataService _dataService; + private readonly ILogService _logService; + private readonly ToolItem? _editingTool; + + // ───────────────────────────── 可观察属性 ───────────────────────────── + + [ObservableProperty] + private string _windowTitle = "添加工具"; + + [ObservableProperty] + private string _name = string.Empty; + + [ObservableProperty] + private string _executablePath = string.Empty; + + [ObservableProperty] + private string _arguments = string.Empty; + + [ObservableProperty] + private string _hotKey = string.Empty; + + [ObservableProperty] + private Category? _selectedCategory; + + /// + /// 分类下拉列表 + /// + public ObservableCollection Categories { get; } = new(); + + /// + /// 窗口关闭回调(由 View 层设置) + /// + public Action? CloseAction { get; set; } + + /// + /// 是否保存成功(供调用方判断是否需要刷新) + /// + public bool Saved { get; private set; } + + // ───────────────────────────── 构造函数 ───────────────────────────── + + public ToolEditViewModel(IDataService dataService, ILogService logService, ToolItem? toolToEdit = null) + { + _dataService = dataService; + _logService = logService; + _editingTool = toolToEdit; + + // 加载分类列表 + foreach (var cat in _dataService.Config.Categories) + Categories.Add(cat); + + // 编辑模式 + if (toolToEdit != null) + { + WindowTitle = "编辑工具"; + Name = toolToEdit.Name; + ExecutablePath = toolToEdit.ExecutablePath; + Arguments = toolToEdit.Arguments; + HotKey = toolToEdit.HotKey; + SelectedCategory = Categories.FirstOrDefault(c => c.Id == toolToEdit.CategoryId); + } + } + + // ───────────────────────────── 命令 ───────────────────────────── + + /// + /// 浏览本地文件 + /// + [RelayCommand] + private void BrowseFile() + { + var dialog = new OpenFileDialog + { + Title = "选择可执行文件或脚本", + Filter = "所有文件|*.*|可执行文件|*.exe|脚本文件|*.bat;*.cmd;*.ps1;*.py|快捷方式|*.lnk" + }; + + if (dialog.ShowDialog() == true) + { + ExecutablePath = dialog.FileName; + if (string.IsNullOrWhiteSpace(Name)) + { + Name = Path.GetFileNameWithoutExtension(dialog.FileName); + } + } + } + + /// + /// 保存工具 + /// + [RelayCommand] + private void Save() + { + try + { + if (string.IsNullOrWhiteSpace(Name)) + { + _logService.Warning("工具名称不能为空"); + return; + } + + if (string.IsNullOrWhiteSpace(ExecutablePath)) + { + _logService.Warning($"工具 \"{Name}\" 路径不能为空"); + return; + } + + // 编辑模式:更新已有工具 + if (_editingTool != null) + { + _editingTool.Name = Name.Trim(); + _editingTool.ExecutablePath = ExecutablePath.Trim(); + _editingTool.Arguments = Arguments.Trim(); + _editingTool.HotKey = HotKey.Trim(); + _editingTool.CategoryId = SelectedCategory?.Id ?? string.Empty; + _editingTool.IsValid = IsExecutablePathValid(ExecutablePath.Trim()); + + _logService.Info($"已更新工具: {Name.Trim()}"); + } + // 添加模式:创建新工具 + else + { + var newTool = new ToolItem + { + Name = Name.Trim(), + ExecutablePath = ExecutablePath.Trim(), + Arguments = Arguments.Trim(), + HotKey = HotKey.Trim(), + CategoryId = SelectedCategory?.Id ?? string.Empty, + IsValid = IsExecutablePathValid(ExecutablePath.Trim()) + }; + + _dataService.Config.Tools.Add(newTool); + _logService.Info($"已添加工具: {Name.Trim()}"); + } + + _dataService.Save(); + Saved = true; + CloseAction?.Invoke(true); + } + catch (Exception ex) + { + _logService.Error($"保存工具失败: {ex.Message}"); + } + } + + /// + /// 取消编辑 + /// + [RelayCommand] + private void Cancel() + { + CloseAction?.Invoke(false); + } + + /// + /// 验证可执行路径是否有效 + /// + private static bool IsExecutablePathValid(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return false; + + // URL 格式(https://...)直接通过 + if (Uri.TryCreate(path, UriKind.Absolute, out var uri) && + (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) + return true; + + // 纯文件名(无路径分隔符)可能位于系统 PATH 中 + if (!path.Contains('\\') && !path.Contains('/')) + return true; + + return File.Exists(path); + } +} diff --git a/PersonalToolBox/Views/MainWindow.xaml b/PersonalToolBox/Views/MainWindow.xaml index 12e377d..3dcd302 100644 --- a/PersonalToolBox/Views/MainWindow.xaml +++ b/PersonalToolBox/Views/MainWindow.xaml @@ -4,10 +4,273 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:local="clr-namespace:PersonalToolBox.Views" + xmlns:vm="clr-namespace:PersonalToolBox.ViewModels" + xmlns:models="clr-namespace:PersonalToolBox.Models" mc:Ignorable="d" - Title="个人工具箱" Height="600" Width="900" - WindowStartupLocation="CenterScreen"> - + Title="个人工具箱" Height="650" Width="960" + WindowStartupLocation="CenterScreen" + Background="{DynamicResource Theme.Background}"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +