Phase 4: 全局快捷键拦截 (Win32 API)

- 新增 HotKeyManager (Helpers/), 使用 user32.dll RegisterHotKey/UnregisterHotKey 注册系统级快捷键

- 支持 Ctrl/Alt/Shift/Win 修饰键组合, 单字符键和命名键(F1-F12等)解析

- MainWindow.OnSourceInitialized 挂载 HwndSource.AddHook 拦截 WM_HOTKEY 消息

- 启动时自动注册所有含快捷键的工具, 工具增删改后自动重新注册

- 快捷键冲突时记录 Win32 错误码, 无效格式打印警告

- 新增 12 个 HotKeyManager 单元测试 (73 tests total)
This commit is contained in:
2026-05-09 23:07:44 +08:00
parent dc94d17d99
commit 92beb46f22
6 changed files with 362 additions and 4 deletions

View File

@@ -0,0 +1,135 @@
using Moq;
using PersonalToolBox.Helpers;
using PersonalToolBox.Models;
using PersonalToolBox.Services;
namespace PersonalToolBox.Tests.Helpers;
public class HotKeyManagerTests
{
[Fact]
public void TryParseHotKey_ValidCtrlAltT_ReturnsCorrectModifiersAndVk()
{
var result = HotKeyManager.TryParseHotKey("Ctrl+Alt+T", out uint mod, out uint vk);
Assert.True(result);
Assert.NotEqual(0u, mod);
// 'T' → VK = 0x54
Assert.Equal((uint)'T', vk);
}
[Fact]
public void TryParseHotKey_ValidCtrlShiftF1_ReturnsCorrectVk()
{
var result = HotKeyManager.TryParseHotKey("Ctrl+Shift+F1", out uint mod, out uint vk);
Assert.True(result);
Assert.NotEqual(0u, mod);
// F1 → VK_F1 = 0x70
Assert.Equal(0x70u, vk);
}
[Fact]
public void TryParseHotKey_OnlyModifier_ReturnsFalse()
{
var result = HotKeyManager.TryParseHotKey("Ctrl", out _, out _);
Assert.False(result);
}
[Fact]
public void TryParseHotKey_OnlyKey_ReturnsFalse()
{
var result = HotKeyManager.TryParseHotKey("T", out _, out _);
Assert.False(result);
}
[Fact]
public void TryParseHotKey_Empty_ReturnsFalse()
{
var result = HotKeyManager.TryParseHotKey("", out _, out _);
Assert.False(result);
}
[Fact]
public void TryParseHotKey_Null_ReturnsFalse()
{
var result = HotKeyManager.TryParseHotKey(null!, out _, out _);
Assert.False(result);
}
[Fact]
public void TryParseHotKey_Whitespace_ReturnsFalse()
{
var result = HotKeyManager.TryParseHotKey(" ", out _, out _);
Assert.False(result);
}
[Fact]
public void TryParseHotKey_WinKey_B_ReturnsCorrectVk()
{
var result = HotKeyManager.TryParseHotKey("Win+B", out uint mod, out uint vk);
Assert.True(result);
Assert.Equal((uint)'B', vk);
}
[Fact]
public void TryParseHotKey_UnknownModifier_ReturnsFalse()
{
var result = HotKeyManager.TryParseHotKey("Foo+T", out _, out _);
Assert.False(result);
}
[Fact]
public void RegisterAll_SkipsEmptyHotKeys()
{
var logMock = new Mock<ILogService>();
var processMock = new Mock<IProcessExecutionService>();
var manager = new HotKeyManager(logMock.Object, processMock.Object);
var tools = new[]
{
new ToolItem { Name = "NoHotkey", HotKey = "" },
new ToolItem { Name = "BlankHotkey", HotKey = " " }
};
// Should not throw
manager.RegisterAll(tools);
}
[Fact]
public void RegisterAll_InvalidFormat_LogsWarning()
{
var logMock = new Mock<ILogService>();
var processMock = new Mock<IProcessExecutionService>();
var manager = new HotKeyManager(logMock.Object, processMock.Object);
// Initialize needs a hwnd, but RegisterAll calls Register which checks _hwnd
// So calling RegisterAll without Initialize should be a no-op
var tools = new[]
{
new ToolItem { Name = "Bad", HotKey = "not-a-valid-key" }
};
// hwnd is IntPtr.Zero, so should skip silently
manager.RegisterAll(tools);
// hwnd is zero, so Register is skipped entirely
logMock.Verify(x => x.Warning(It.IsAny<string>()), Times.Never);
}
[Fact]
public void TryHandleMessage_NonWMHOTKEY_ReturnsFalse()
{
var logMock = new Mock<ILogService>();
var processMock = new Mock<IProcessExecutionService>();
var manager = new HotKeyManager(logMock.Object, processMock.Object);
var handled = manager.TryHandleMessage(9999, IntPtr.Zero, IntPtr.Zero);
Assert.False(handled);
}
}