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:
2026-05-09 21:52:31 +08:00
parent 752f09a7e4
commit 71be5da54b
22 changed files with 1991 additions and 22 deletions

View 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;
}
}
}

View 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);
}
}

View File

@@ -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);
}
}