From bbc183cef6b86b69b57bc7393177a83de953624c Mon Sep 17 00:00:00 2001 From: home-PC Date: Wed, 27 May 2026 14:59:07 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E5=A2=9E=E5=8A=A0=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E7=AE=B1=E6=9C=AC=E5=9C=B0=E9=AA=8C=E8=AF=81=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增不依赖外部 NuGet 的测试项目,覆盖快捷键解析、组合校验、路径和网址校验,以及配置读写、导出和数据版本策略。 同步维护 development.md,记录本轮完成情况、后续可选打磨点和最新验证命令。 --- PersonalToolbox.sln | 9 + docs/development.md | 68 ++++++- .../PersonalToolbox.Tests.csproj | 15 ++ tests/PersonalToolbox.Tests/Program.cs | 191 ++++++++++++++++++ 4 files changed, 273 insertions(+), 10 deletions(-) create mode 100644 tests/PersonalToolbox.Tests/PersonalToolbox.Tests.csproj create mode 100644 tests/PersonalToolbox.Tests/Program.cs diff --git a/PersonalToolbox.sln b/PersonalToolbox.sln index f19fd31..b2465f0 100644 --- a/PersonalToolbox.sln +++ b/PersonalToolbox.sln @@ -7,6 +7,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{DF1687D8-B6F EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PersonalToolbox", "src\PersonalToolbox\PersonalToolbox.csproj", "{9A4ED1BF-510A-4481-AE7B-62490D3D7BBA}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{66058A29-88BE-4215-BB4B-1F298EC16616}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PersonalToolbox.Tests", "tests\PersonalToolbox.Tests\PersonalToolbox.Tests.csproj", "{7E11D95E-93C3-4CA4-A24C-25CF15CA1A60}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -20,8 +24,13 @@ Global {9A4ED1BF-510A-4481-AE7B-62490D3D7BBA}.Debug|Any CPU.Build.0 = Debug|Any CPU {9A4ED1BF-510A-4481-AE7B-62490D3D7BBA}.Release|Any CPU.ActiveCfg = Release|Any CPU {9A4ED1BF-510A-4481-AE7B-62490D3D7BBA}.Release|Any CPU.Build.0 = Release|Any CPU + {7E11D95E-93C3-4CA4-A24C-25CF15CA1A60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7E11D95E-93C3-4CA4-A24C-25CF15CA1A60}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7E11D95E-93C3-4CA4-A24C-25CF15CA1A60}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7E11D95E-93C3-4CA4-A24C-25CF15CA1A60}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {9A4ED1BF-510A-4481-AE7B-62490D3D7BBA} = {DF1687D8-B6FA-4DF9-90EC-E749B85024EC} + {7E11D95E-93C3-4CA4-A24C-25CF15CA1A60} = {66058A29-88BE-4215-BB4B-1F298EC16616} EndGlobalSection EndGlobal diff --git a/docs/development.md b/docs/development.md index 2066aa5..48e9875 100644 --- a/docs/development.md +++ b/docs/development.md @@ -5,7 +5,7 @@ - 项目名称:Personal Toolbox / PersonalToolbox - 技术栈:WPF + .NET 8 + C# - 架构方向:MVVM 分层,UI 层通过 ViewModel 调用服务层 -- 当前阶段:MVP 基础版本已搭建,可编译运行 +- 当前阶段:MVP 主要能力已补齐,可编译运行并具备本地测试覆盖 - 主要入口:`src/PersonalToolbox/MainWindow.xaml` - 配置目录:`%AppData%\PersonalToolbox` @@ -20,6 +20,7 @@ 2. 数据与配置 - 本地 JSON 配置读写 - 原子写入:先写 `.tmp`,再替换正式文件 + - `dataVersion` 当前为 2,加载和导入时会拒绝高于当前支持版本的配置 - 配置文件: - `appsettings.json` - `categories.json` @@ -27,6 +28,7 @@ - `autorun.json` - `icons/` - 导入、导出、重置配置基础能力 + - 导出配置只打包目标配置文件和 `icons` 目录,不包含 `backups` 等运行期备份目录 - 路径失效检查会输出具体工具、字段、原因和路径 3. 工具模型 @@ -73,12 +75,58 @@ - 工具编辑窗口 - 组合编辑窗口 - 设置窗口 + - 外观设置页:主题、卡片大小、界面缩放、是否显示工具说明 + - 轻量图标库、图标选择器、本地图标导入、自定义图标目录和本地工具关联图标缓存 + - 工具和组合编辑窗口支持捕获式快捷键录入与清除 + - 主界面卡片右键菜单支持启动、编辑、重命名、复制、移动分类、切换自动运行、修改路径和删除 + - 分类支持图标编辑;分类和卡片支持拖拽排序,工具可拖拽移动到分类 - 主要 UI 元素均提供 `ToolTip` 悬浮说明 +## 与产品设计文档对照后的完成情况 + +以下内容来自 `windows_personal_toolbox_product_design.md` 与当前项目实现之间的对照结果。 + +1. 图标系统 + - 已提供轻量内置图标库、工具/组合/分类图标选择器、本地图片 / ico 导入和 `icons/custom` 存储。 + - 本地工具会尽量从目标文件提取关联图标并缓存到 `icons/cache`;提取失败时按文件类型回退到内置图标。 + - 第一版不做 favicon 下载和在线图标市场。 + +2. 外观设置 + - 设置页已接入跟随系统 / 浅色 / 深色主题、卡片大小、界面缩放和是否显示工具说明。 + - 主界面保存设置后即时应用外观变化。 + +3. 拖拽与卡片管理 + - 分类支持拖拽排序。 + - 工具卡片支持拖拽排序,也可拖到左侧分类完成移动。 + - 卡片右键菜单已补齐启动、编辑、重命名、复制、移动分类、切换启动时自动运行、修改路径和删除。 + +4. 快捷键录入 + - 工具编辑、组合编辑和设置页快捷键管理均支持捕获式录入和清除。 + - 仍保留文本校验,便于用户直接粘贴或手动修正快捷键。 + +5. 导入导出和数据版本 + - `dataVersion` 已升级为 2。 + - 加载和导入时会校验数据版本,拒绝当前应用不支持的更高版本。 + - 导出配置仅包含设计文档要求的配置文件和 `icons` 目录,不导出 `backups`。 + +6. 测试覆盖 + - 新增 `tests/PersonalToolbox.Tests` 本地测试项目,不依赖外部 NuGet 包。 + - 已覆盖快捷键解析、组合校验、路径/网址校验、配置读写、导出排除备份目录和数据版本拒绝策略。 + +后续可选打磨: + +1. 为组合成员和自动运行列表补充原生拖拽排序,目前仍保留稳定的上移 / 下移按钮。 +2. 继续细化图标提取策略,例如快捷方式目标图标和更多系统文件类型图标。 +3. 增加 UI 自动化或截图验收,覆盖托盘、弹窗和拖拽等桌面交互。 + ## 最近开发记录 ### 2026-05-27 +- 补齐图标系统、外观设置、快捷键捕获、卡片右键菜单、分类图标编辑和主界面拖拽排序。 +- `dataVersion` 升级为 2,导入和加载会拒绝更高版本配置;导出配置排除 `backups` 运行期备份目录。 +- 新增 `tests/PersonalToolbox.Tests` 本地测试项目,覆盖快捷键、组合校验、路径校验和配置读写/导出/版本策略。 +- 验证命令:`dotnet build PersonalToolbox.sln`,结果为 0 警告、0 错误;`dotnet run --project tests\PersonalToolbox.Tests\PersonalToolbox.Tests.csproj`,结果为 4 组测试全部通过。 - 将项目内部名从旧临时代号统一更改为 `PersonalToolbox`,包括目录、项目文件、命名空间、XAML 类名、配置目录、自启注册表值和导出文件名。 - 设计文档中的临时代号和 AppData 路径同步改为 `PersonalToolbox`。 - 路径失效检查从单纯数量扩展为明细报告,设置页导入配置后也会提示具体失效工具。 @@ -104,6 +152,12 @@ dotnet run --project src\PersonalToolbox\PersonalToolbox.csproj dotnet build PersonalToolbox.sln 0 个警告 0 个错误 + +dotnet run --project tests\PersonalToolbox.Tests\PersonalToolbox.Tests.csproj +PASS 快捷键解析和捕获格式化 +PASS 组合校验覆盖循环、重复和缺失引用 +PASS 路径和网址校验 +PASS 配置读写、导出和数据版本校验 ``` ## 目录说明 @@ -115,6 +169,9 @@ src/PersonalToolbox ├─ Services 配置、启动、托盘、自启、快捷键、路径校验等服务 ├─ ViewModels 主界面和卡片视图模型 └─ Views 编辑、组合、设置和提示窗口 + +tests/PersonalToolbox.Tests +└─ Program.cs 轻量本地测试入口,不依赖外部测试框架 ``` ## 开发需知 @@ -147,12 +204,3 @@ feat: 添加某项用户能力 - `chore`: 构建、仓库、维护类变更 - `refactor`: 不改变行为的重构 - `test`: 测试相关 - -## 后续优先事项 - -1. 自动图标提取和轻量内置图标库。 -2. 更完整的图标选择器和分类图标编辑。 -3. 更细的快捷键录入控件,减少手写格式错误。 -4. 拖拽调整卡片和分类顺序。 -5. UI 视觉打磨和深浅色主题。 -6. 为组合校验、快捷键解析和配置读写增加单元测试。 diff --git a/tests/PersonalToolbox.Tests/PersonalToolbox.Tests.csproj b/tests/PersonalToolbox.Tests/PersonalToolbox.Tests.csproj new file mode 100644 index 0000000..35647a9 --- /dev/null +++ b/tests/PersonalToolbox.Tests/PersonalToolbox.Tests.csproj @@ -0,0 +1,15 @@ + + + + Exe + net8.0-windows + enable + enable + true + + + + + + + diff --git a/tests/PersonalToolbox.Tests/Program.cs b/tests/PersonalToolbox.Tests/Program.cs new file mode 100644 index 0000000..cda7e83 --- /dev/null +++ b/tests/PersonalToolbox.Tests/Program.cs @@ -0,0 +1,191 @@ +using System.IO.Compression; +using System.IO; +using System.Windows.Input; +using PersonalToolbox.Models; +using PersonalToolbox.Services; + +var tests = new (string Name, Func Run)[] +{ + ("快捷键解析和捕获格式化", TestHotkeys), + ("组合校验覆盖循环、重复和缺失引用", TestCombinationValidation), + ("路径和网址校验", TestPathValidation), + ("配置读写、导出和数据版本校验", TestConfiguration) +}; + +var failed = 0; +foreach (var test in tests) +{ + try + { + await test.Run(); + Console.WriteLine($"PASS {test.Name}"); + } + catch (Exception ex) + { + failed++; + Console.WriteLine($"FAIL {test.Name}"); + Console.WriteLine(ex); + } +} + +if (failed > 0) +{ + Environment.Exit(1); +} + +static Task TestHotkeys() +{ + Assert(HotkeyParser.TryParse("Ctrl + Alt + T", out _, out _), "Ctrl + Alt + T 应可解析"); + Assert(HotkeyParser.Normalize("alt + ctrl + t") == "Ctrl+Alt+T", "快捷键应按固定顺序归一化"); + Assert(!HotkeyParser.TryParse("T", out _, out _), "纯单键快捷键不应被接受"); + Assert(HotkeyParser.TryFormatFromInput(ModifierKeys.Control | ModifierKeys.Shift, Key.D, out var hotkey), "捕获输入应成功"); + Assert(hotkey == "Ctrl+Shift+D", "捕获输入应生成规范文本"); + return Task.CompletedTask; +} + +static Task TestCombinationValidation() +{ + var launcher = new ToolLaunchService(); + var toolA = CreateTool("tool-a", "工具 A"); + var toolB = CreateTool("tool-b", "工具 B"); + var child = CreateCombination("combo-child", "子组合", "tool-b"); + var parent = CreateCombination("combo-parent", "父组合", "tool-a", "combo-child"); + + var valid = launcher.ValidateCombination(parent, [toolA, toolB, child, parent]); + Assert(valid.Success, "不重复的嵌套组合应通过校验"); + + child.Combination!.Members.Add(new CombinationMember { ToolId = "tool-a", SortOrder = 1 }); + var duplicate = launcher.ValidateCombination(parent, [toolA, toolB, child, parent]); + Assert(!duplicate.Success && duplicate.Kind == LaunchResultKind.DuplicateTool, "展开后重复工具应被拦截"); + + child.Combination.Members.Clear(); + child.Combination.Members.Add(new CombinationMember { ToolId = "combo-parent" }); + var circular = launcher.ValidateCombination(parent, [toolA, child, parent]); + Assert(!circular.Success && circular.Kind == LaunchResultKind.CircularReference, "循环引用应被拦截"); + + child.Combination.Members.Clear(); + child.Combination.Members.Add(new CombinationMember { ToolId = "missing-tool" }); + var missing = launcher.ValidateCombination(parent, [toolA, child, parent]); + Assert(!missing.Success && missing.Kind == LaunchResultKind.MissingReference, "缺失成员引用应被拦截"); + return Task.CompletedTask; +} + +static Task TestPathValidation() +{ + Assert(PathValidationService.IsValidUrl("example.com", out var url), "无协议网址应自动补全"); + Assert(url.StartsWith("https://example.com", StringComparison.OrdinalIgnoreCase), "网址应补全为 https"); + Assert(!PathValidationService.IsValidUrl("file://c:/test.txt", out _), "第一版只接受 http/https"); + + var tool = CreateTool("local-missing", "缺失工具"); + tool.LaunchTarget = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"), "missing.exe"); + var report = new PathValidationService().ValidateTools([tool]); + Assert(report.HasIssues, "缺失路径应生成检查问题"); + Assert(tool.PathInvalid, "缺失路径应标记到工具模型"); + return Task.CompletedTask; +} + +static async Task TestConfiguration() +{ + var root = Path.Combine(Path.GetTempPath(), "PersonalToolboxTests_" + Guid.NewGuid().ToString("N")); + var service = new ConfigurationService(root); + var data = new ToolboxData + { + Settings = new AppSettings + { + Theme = "Dark", + CardSize = "Large", + UiScale = 1.2, + ShowToolDescriptions = false + }, + Categories = + [ + new CategoryItem { Id = "category-test", Name = "测试分类", IconKey = "dev", SortOrder = 0 } + ], + Tools = + [ + CreateTool("tool-config", "配置工具") + ], + AutoRunEntries = [] + }; + data.Tools[0].CategoryId = "category-test"; + + await service.SaveAsync(data); + var loaded = await service.LoadAsync(); + Assert(loaded.Settings.DataVersion == ConfigurationService.CurrentDataVersion, "加载后应升级到当前数据版本"); + Assert(loaded.Settings.Theme == "Dark", "设置应完成往返保存"); + Assert(loaded.Tools.Any(tool => tool.Id == "tool-config"), "工具应完成往返保存"); + + var backupDirectory = Path.Combine(root, "backups", "should-not-export"); + Directory.CreateDirectory(backupDirectory); + await File.WriteAllTextAsync(Path.Combine(backupDirectory, "ignored.txt"), "ignored"); + + var zipPath = Path.Combine(Path.GetTempPath(), "PersonalToolboxExport_" + Guid.NewGuid().ToString("N") + ".zip"); + await service.ExportAsync(zipPath, loaded); + using (var archive = ZipFile.OpenRead(zipPath)) + { + Assert(archive.GetEntry("appsettings.json") is not null, "导出应包含 appsettings.json"); + Assert(archive.Entries.All(entry => !entry.FullName.StartsWith("backups/", StringComparison.OrdinalIgnoreCase)), "导出不应包含 backups 目录"); + } + + var futureRoot = Path.Combine(Path.GetTempPath(), "PersonalToolboxTests_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(futureRoot); + await File.WriteAllTextAsync(Path.Combine(futureRoot, "appsettings.json"), "{\"dataVersion\":999}"); + var futureService = new ConfigurationService(futureRoot); + await AssertThrowsAsync(() => futureService.LoadAsync(), "高版本数据应被拒绝"); +} + +static ToolItem CreateTool(string id, string name) +{ + return new ToolItem + { + Id = id, + Name = name, + Type = ToolType.Local, + CategoryId = "category-test", + LaunchTarget = Environment.ProcessPath ?? "dotnet", + IconKey = "local" + }; +} + +static ToolItem CreateCombination(string id, string name, params string[] members) +{ + return new ToolItem + { + Id = id, + Name = name, + Type = ToolType.Combination, + CategoryId = "category-test", + IconKey = "combination", + Combination = new CombinationConfig + { + Members = members.Select((toolId, index) => new CombinationMember + { + ToolId = toolId, + SortOrder = index + }).ToList() + } + }; +} + +static async Task AssertThrowsAsync(Func action, string message) + where TException : Exception +{ + try + { + await action(); + } + catch (TException) + { + return; + } + + throw new InvalidOperationException(message); +} + +static void Assert(bool condition, string message) +{ + if (!condition) + { + throw new InvalidOperationException(message); + } +}