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:
118
PersonalToolBox.Tests/Models/ModelSerializationTests.cs
Normal file
118
PersonalToolBox.Tests/Models/ModelSerializationTests.cs
Normal file
@@ -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<AppConfig>(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<AppConfig>(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<AppConfig>(json, Options);
|
||||
|
||||
Assert.NotNull(config);
|
||||
Assert.Equal("Light", config.Theme);
|
||||
}
|
||||
}
|
||||
29
PersonalToolBox.Tests/PersonalToolBox.Tests.csproj
Normal file
29
PersonalToolBox.Tests/PersonalToolBox.Tests.csproj
Normal file
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseWPF>true</UseWPF>
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="xunit" Version="2.5.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\PersonalToolBox\PersonalToolBox.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
194
PersonalToolBox.Tests/Services/JsonDataServiceTests.cs
Normal file
194
PersonalToolBox.Tests/Services/JsonDataServiceTests.cs
Normal file
@@ -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<ILogService> _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<string>(s => s.Contains(fragment))), Times.AtLeastOnce);
|
||||
break;
|
||||
case LogLevel.Warning:
|
||||
_logServiceMock.Verify(x => x.Warning(It.Is<string>(s => s.Contains(fragment))), Times.AtLeastOnce);
|
||||
break;
|
||||
case LogLevel.Error:
|
||||
_logServiceMock.Verify(x => x.Error(It.Is<string>(s => s.Contains(fragment))), Times.AtLeastOnce);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
96
PersonalToolBox.Tests/Services/LogServiceTests.cs
Normal file
96
PersonalToolBox.Tests/Services/LogServiceTests.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using Moq;
|
||||
using PersonalToolBox.Models;
|
||||
using PersonalToolBox.Services;
|
||||
|
||||
namespace PersonalToolBox.Tests.Services;
|
||||
|
||||
public class ProcessExecutionServiceTests
|
||||
{
|
||||
private readonly Mock<ILogService> _logServiceMock = new();
|
||||
|
||||
[Fact]
|
||||
public void Execute_NullTool_LogsError()
|
||||
{
|
||||
var service = new ProcessExecutionService(_logServiceMock.Object);
|
||||
|
||||
service.Execute(null!);
|
||||
|
||||
_logServiceMock.Verify(x => x.Error(It.Is<string>(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<string>(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<string>(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<string>(s => s.Contains("成功启动") && s.Contains("记事本"))), Times.Once);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
67
PersonalToolBox.Tests/Views/ConverterTests.cs
Normal file
67
PersonalToolBox.Tests/Views/ConverterTests.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user