From 13d140bcfe1e1b3015b1d4a0b52996d5e898b078 Mon Sep 17 00:00:00 2001 From: "zhengxuan.zhang" Date: Sat, 14 Mar 2026 23:58:38 +0800 Subject: [PATCH] =?UTF-8?q?#0021=20=E5=A2=9E=E5=8A=A0=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E5=B7=A5=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- XplorePlane.Tests/Helpers/TestHelpers.cs | 76 +++++ .../Pipeline/OperatorToolboxViewModelTests.cs | 103 +++++++ .../Pipeline/PipelineEditorViewModelTests.cs | 275 +++++++++++++++++ .../Pipeline/PipelineExecutionServiceTests.cs | 215 ++++++++++++++ .../PipelinePersistenceServiceTests.cs | 203 +++++++++++++ .../Pipeline/PipelinePropertyTests.cs | 278 ++++++++++++++++++ XplorePlane.Tests/XplorePlane.Tests.csproj | 33 +++ .../Hardware/en-US/XP.Common.resources.dll | Bin 0 -> 10240 bytes .../Hardware/zh-CN/XP.Common.resources.dll | Bin 0 -> 9216 bytes .../Hardware/zh-TW/XP.Common.resources.dll | Bin 0 -> 9728 bytes XplorePlane/Views/OperatorToolboxView.xaml | 4 +- XplorePlane/Views/OperatorToolboxView.xaml.cs | 39 ++- XplorePlane/Views/PipelineEditorView.xaml | 1 - XplorePlane/Views/PipelineEditorView.xaml.cs | 25 +- XplorePlane/readme.txt | 2 +- 15 files changed, 1235 insertions(+), 19 deletions(-) create mode 100644 XplorePlane.Tests/Helpers/TestHelpers.cs create mode 100644 XplorePlane.Tests/Pipeline/OperatorToolboxViewModelTests.cs create mode 100644 XplorePlane.Tests/Pipeline/PipelineEditorViewModelTests.cs create mode 100644 XplorePlane.Tests/Pipeline/PipelineExecutionServiceTests.cs create mode 100644 XplorePlane.Tests/Pipeline/PipelinePersistenceServiceTests.cs create mode 100644 XplorePlane.Tests/Pipeline/PipelinePropertyTests.cs create mode 100644 XplorePlane.Tests/XplorePlane.Tests.csproj create mode 100644 XplorePlane/Libs/Hardware/en-US/XP.Common.resources.dll create mode 100644 XplorePlane/Libs/Hardware/zh-CN/XP.Common.resources.dll create mode 100644 XplorePlane/Libs/Hardware/zh-TW/XP.Common.resources.dll diff --git a/XplorePlane.Tests/Helpers/TestHelpers.cs b/XplorePlane.Tests/Helpers/TestHelpers.cs new file mode 100644 index 0000000..7ade978 --- /dev/null +++ b/XplorePlane.Tests/Helpers/TestHelpers.cs @@ -0,0 +1,76 @@ +using System.Windows; +using System.Collections.Generic; +using System.Threading; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using ImageProcessing.Core; +using Moq; +using XplorePlane.Services; +using System; + +namespace XplorePlane.Tests.Helpers +{ + /// + /// 测试辅助工具:创建 Mock 和测试用图像 + /// + internal static class TestHelpers + { + public static readonly string[] DefaultKeys = { "Blur", "Sharpen", "Threshold" }; + + /// + /// 创建一个 1x1 像素的 BitmapSource,用于测试 + /// + public static BitmapSource CreateTestBitmap(int width = 4, int height = 4) + { + var pixels = new byte[width * height * 4]; + var bitmap = BitmapSource.Create( + width, height, 96, 96, + PixelFormats.Bgra32, null, + pixels, width * 4); + bitmap.Freeze(); + return bitmap; + } + + /// + /// 创建一个超大图像(用于测试预览缩放) + /// + public static BitmapSource CreateLargeBitmap(int width = 4096, int height = 4096) + => CreateTestBitmap(width, height); + + /// + /// 创建标准 Mock IImageProcessingService,注册 DefaultKeys 中的算子 + /// + public static Mock CreateMockImageService( + string[]? keys = null, + ProcessorParameter[]? parameters = null) + { + keys ??= DefaultKeys; + parameters ??= Array.Empty(); + + var mock = new Mock(); + mock.Setup(s => s.GetAvailableProcessors()).Returns(keys); + mock.Setup(s => s.GetProcessorDisplayName(It.IsAny())) + .Returns(k => k + "算子"); + mock.Setup(s => s.GetProcessorParameters(It.IsAny())) + .Returns(parameters); + mock.Setup(s => s.ProcessImageAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync((BitmapSource src, string _, IDictionary _, IProgress _, CancellationToken ct) => + { + ct.ThrowIfCancellationRequested(); + return src; + }); + return mock; + } + + /// + /// 创建带数值参数的 ProcessorParameter + /// + public static ProcessorParameter MakeIntParam(string name, int value = 5, int min = 0, int max = 10) + => new ProcessorParameter(name, name, typeof(int), value, min, max); + } +} diff --git a/XplorePlane.Tests/Pipeline/OperatorToolboxViewModelTests.cs b/XplorePlane.Tests/Pipeline/OperatorToolboxViewModelTests.cs new file mode 100644 index 0000000..caabb58 --- /dev/null +++ b/XplorePlane.Tests/Pipeline/OperatorToolboxViewModelTests.cs @@ -0,0 +1,103 @@ +using Xunit; +using XplorePlane.ViewModels; +using XplorePlane.Tests.Helpers; +using System; + +namespace XplorePlane.Tests.Pipeline +{ + /// + /// 测试 OperatorToolboxViewModel 搜索过滤逻辑(任务 6.7) + /// + public class OperatorToolboxViewModelTests + { + private OperatorToolboxViewModel CreateVm(string[]? keys = null) + { + var mock = TestHelpers.CreateMockImageService(keys); + return new OperatorToolboxViewModel(mock.Object); + } + + [Fact] + public void EmptySearch_ReturnsAllOperators() + { + var vm = CreateVm(new[] { "Blur", "Sharpen", "Threshold" }); + vm.SearchText = string.Empty; + + Assert.Equal(3, vm.FilteredOperators.Count); + } + + [Fact] + public void NullSearch_ReturnsAllOperators() + { + var vm = CreateVm(new[] { "Blur", "Sharpen", "Threshold" }); + vm.SearchText = null!; + + Assert.Equal(3, vm.FilteredOperators.Count); + } + + [Fact] + public void WhitespaceSearch_ReturnsAllOperators() + { + var vm = CreateVm(new[] { "Blur", "Sharpen", "Threshold" }); + vm.SearchText = " "; + + Assert.Equal(3, vm.FilteredOperators.Count); + } + + [Fact] + public void SearchText_FiltersMatchingOperators() + { + // DisplayName = Key + "算子",所以 "Blur算子" 包含 "Blur" + var vm = CreateVm(new[] { "Blur", "Sharpen", "Threshold" }); + vm.SearchText = "Blur"; + + Assert.Single(vm.FilteredOperators); + Assert.Equal("Blur", vm.FilteredOperators[0].Key); + } + + [Fact] + public void SearchText_CaseInsensitive() + { + var vm = CreateVm(new[] { "Blur", "Sharpen", "Threshold" }); + vm.SearchText = "blur"; + + Assert.Single(vm.FilteredOperators); + } + + [Fact] + public void SearchText_NoMatch_ReturnsEmpty() + { + var vm = CreateVm(new[] { "Blur", "Sharpen", "Threshold" }); + vm.SearchText = "XYZ_NOT_EXIST"; + + Assert.Empty(vm.FilteredOperators); + } + + [Fact] + public void SearchText_PartialMatch_ReturnsMatching() + { + var vm = CreateVm(new[] { "Blur", "Sharpen", "Threshold" }); + vm.SearchText = "算子"; // 所有 DisplayName 都包含"算子" + + Assert.Equal(3, vm.FilteredOperators.Count); + } + + [Fact] + public void FilteredOperators_EachContainsSearchText() + { + var vm = CreateVm(new[] { "Blur", "Sharpen", "Threshold" }); + vm.SearchText = "Sh"; + + foreach (var op in vm.FilteredOperators) + Assert.Contains("Sh", op.DisplayName, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void NoOperators_EmptySearch_ReturnsEmpty() + { + var vm = CreateVm(Array.Empty()); + vm.SearchText = string.Empty; + + Assert.Empty(vm.FilteredOperators); + } + } +} diff --git a/XplorePlane.Tests/Pipeline/PipelineEditorViewModelTests.cs b/XplorePlane.Tests/Pipeline/PipelineEditorViewModelTests.cs new file mode 100644 index 0000000..f6a7c59 --- /dev/null +++ b/XplorePlane.Tests/Pipeline/PipelineEditorViewModelTests.cs @@ -0,0 +1,275 @@ +using Moq; +using Xunit; +using XplorePlane.Models; +using XplorePlane.Services; +using XplorePlane.ViewModels; +using XplorePlane.Tests.Helpers; + +namespace XplorePlane.Tests.Pipeline +{ + /// + /// 测试 PipelineEditorViewModel 的命令逻辑 + /// 覆盖任务 6.1 / 6.2 / 6.3 / 6.4 / 6.8 + /// + public class PipelineEditorViewModelTests + { + private readonly Mock _mockImageSvc; + private readonly Mock _mockExecSvc; + private readonly Mock _mockPersistSvc; + + public PipelineEditorViewModelTests() + { + _mockImageSvc = TestHelpers.CreateMockImageService(); + _mockExecSvc = new Mock(); + _mockPersistSvc = new Mock(); + } + + private PipelineEditorViewModel CreateVm() => + new PipelineEditorViewModel(_mockImageSvc.Object, _mockExecSvc.Object, _mockPersistSvc.Object); + + // ── 6.1 AddOperatorCommand ──────────────────────────────────── + + [Fact] + public void AddOperator_ValidKey_AppendsNode() + { + var vm = CreateVm(); + vm.AddOperatorCommand.Execute("Blur"); + + Assert.Single(vm.PipelineNodes); + Assert.Equal("Blur", vm.PipelineNodes[0].OperatorKey); + Assert.Equal(0, vm.PipelineNodes[0].Order); + } + + [Fact] + public void AddOperator_InvalidKey_NodeNotAdded() + { + var vm = CreateVm(); + vm.AddOperatorCommand.Execute("NonExistentKey"); + + Assert.Empty(vm.PipelineNodes); + Assert.Contains("未注册", vm.StatusMessage); + } + + [Fact] + public void AddOperator_NullKey_NodeNotAdded() + { + var vm = CreateVm(); + vm.AddOperatorCommand.Execute(null); + + Assert.Empty(vm.PipelineNodes); + } + + [Fact] + public void AddOperator_ExceedsMaxLength_NodeNotAdded() + { + // 主项目中 MaxPipelineLength = 20,通过反射读取或直接添加 20 个 + var vm = CreateVm(); + for (int i = 0; i < 20; i++) + vm.AddOperatorCommand.Execute("Blur"); + + Assert.Equal(20, vm.PipelineNodes.Count); + + vm.AddOperatorCommand.Execute("Blur"); + + Assert.Equal(20, vm.PipelineNodes.Count); + Assert.Contains("上限", vm.StatusMessage); + } + + [Fact] + public void AddOperator_MultipleNodes_OrderIsSequential() + { + var vm = CreateVm(); + vm.AddOperatorCommand.Execute("Blur"); + vm.AddOperatorCommand.Execute("Sharpen"); + vm.AddOperatorCommand.Execute("Threshold"); + + Assert.Equal(3, vm.PipelineNodes.Count); + for (int i = 0; i < 3; i++) + Assert.Equal(i, vm.PipelineNodes[i].Order); + } + + // ── 6.2 RemoveOperatorCommand ───────────────────────────────── + + [Fact] + public void RemoveOperator_ExistingNode_NodeRemoved() + { + var vm = CreateVm(); + vm.AddOperatorCommand.Execute("Blur"); + var node = vm.PipelineNodes[0]; + + vm.RemoveOperatorCommand.Execute(node); + + Assert.Empty(vm.PipelineNodes); + } + + [Fact] + public void RemoveOperator_MiddleNode_OrderRenumbered() + { + var vm = CreateVm(); + vm.AddOperatorCommand.Execute("Blur"); + vm.AddOperatorCommand.Execute("Sharpen"); + vm.AddOperatorCommand.Execute("Threshold"); + var middle = vm.PipelineNodes[1]; + + vm.RemoveOperatorCommand.Execute(middle); + + Assert.Equal(2, vm.PipelineNodes.Count); + Assert.Equal(0, vm.PipelineNodes[0].Order); + Assert.Equal(1, vm.PipelineNodes[1].Order); + } + + [Fact] + public void RemoveOperator_SelectedNode_SelectedNodeCleared() + { + var vm = CreateVm(); + vm.AddOperatorCommand.Execute("Blur"); + var node = vm.PipelineNodes[0]; + vm.SelectedNode = node; + + vm.RemoveOperatorCommand.Execute(node); + + Assert.Null(vm.SelectedNode); + } + + [Fact] + public void RemoveOperator_NullNode_NoException() + { + var vm = CreateVm(); + var ex = Record.Exception(() => vm.RemoveOperatorCommand.Execute(null)); + Assert.Null(ex); + } + + // ── 6.3 ReorderOperatorCommand ──────────────────────────────── + + [Fact] + public void ReorderOperator_MoveFirstToLast_OrderConsistent() + { + var vm = CreateVm(); + vm.AddOperatorCommand.Execute("Blur"); + vm.AddOperatorCommand.Execute("Sharpen"); + vm.AddOperatorCommand.Execute("Threshold"); + + vm.ReorderOperatorCommand.Execute(new PipelineReorderArgs { OldIndex = 0, NewIndex = 2 }); + + for (int i = 0; i < vm.PipelineNodes.Count; i++) + Assert.Equal(i, vm.PipelineNodes[i].Order); + } + + [Fact] + public void ReorderOperator_MoveLastToFirst_OrderConsistent() + { + var vm = CreateVm(); + vm.AddOperatorCommand.Execute("Blur"); + vm.AddOperatorCommand.Execute("Sharpen"); + vm.AddOperatorCommand.Execute("Threshold"); + var lastName = vm.PipelineNodes[2].OperatorKey; + + vm.ReorderOperatorCommand.Execute(new PipelineReorderArgs { OldIndex = 2, NewIndex = 0 }); + + Assert.Equal(lastName, vm.PipelineNodes[0].OperatorKey); + for (int i = 0; i < vm.PipelineNodes.Count; i++) + Assert.Equal(i, vm.PipelineNodes[i].Order); + } + + [Fact] + public void ReorderOperator_SameIndex_NoChange() + { + var vm = CreateVm(); + vm.AddOperatorCommand.Execute("Blur"); + vm.AddOperatorCommand.Execute("Sharpen"); + var originalFirst = vm.PipelineNodes[0].OperatorKey; + + vm.ReorderOperatorCommand.Execute(new PipelineReorderArgs { OldIndex = 0, NewIndex = 0 }); + + Assert.Equal(originalFirst, vm.PipelineNodes[0].OperatorKey); + } + + [Fact] + public void ReorderOperator_OutOfBoundsIndex_NoException() + { + var vm = CreateVm(); + vm.AddOperatorCommand.Execute("Blur"); + + var ex = Record.Exception(() => + vm.ReorderOperatorCommand.Execute(new PipelineReorderArgs { OldIndex = 0, NewIndex = 99 })); + Assert.Null(ex); + } + + // ── 6.4 LoadNodeParameters ──────────────────────────────────── + + [Fact] + public void LoadNodeParameters_ModifiedValuePreserved() + { + var param = TestHelpers.MakeIntParam("Radius", value: 5, min: 1, max: 20); + _mockImageSvc.Setup(s => s.GetProcessorParameters("Blur")) + .Returns(new[] { param }); + + var vm = CreateVm(); + vm.AddOperatorCommand.Execute("Blur"); + var node = vm.PipelineNodes[0]; + + // 修改参数值 + node.Parameters[0].Value = 10; + + // 重新选中节点触发 LoadNodeParameters + vm.SelectedNode = null; + vm.SelectedNode = node; + + Assert.Equal(10, node.Parameters[0].Value); + } + + [Fact] + public void LoadNodeParameters_UnmodifiedUsesDefault() + { + var param = TestHelpers.MakeIntParam("Radius", value: 5, min: 1, max: 20); + _mockImageSvc.Setup(s => s.GetProcessorParameters("Blur")) + .Returns(new[] { param }); + + var vm = CreateVm(); + vm.AddOperatorCommand.Execute("Blur"); + var node = vm.PipelineNodes[0]; + + Assert.Equal(5, node.Parameters[0].Value); + } + + // ── 6.8 参数范围校验 ────────────────────────────────────────── + + [Fact] + public void ParameterValidation_ValueWithinRange_IsValid() + { + var param = TestHelpers.MakeIntParam("Radius", value: 5, min: 1, max: 10); + var vm = new ProcessorParameterVM(param) { Value = 7 }; + + Assert.True(vm.IsValueValid); + } + + [Fact] + public void ParameterValidation_ValueBelowMin_IsInvalid() + { + var param = TestHelpers.MakeIntParam("Radius", value: 5, min: 1, max: 10); + var vm = new ProcessorParameterVM(param) { Value = 0 }; + + Assert.False(vm.IsValueValid); + } + + [Fact] + public void ParameterValidation_ValueAboveMax_IsInvalid() + { + var param = TestHelpers.MakeIntParam("Radius", value: 5, min: 1, max: 10); + var vm = new ProcessorParameterVM(param) { Value = 11 }; + + Assert.False(vm.IsValueValid); + } + + [Fact] + public void ParameterValidation_BoundaryValues_AreValid() + { + var param = TestHelpers.MakeIntParam("Radius", value: 5, min: 1, max: 10); + var vmMin = new ProcessorParameterVM(param) { Value = 1 }; + var vmMax = new ProcessorParameterVM(param) { Value = 10 }; + + Assert.True(vmMin.IsValueValid); + Assert.True(vmMax.IsValueValid); + } + } +} diff --git a/XplorePlane.Tests/Pipeline/PipelineExecutionServiceTests.cs b/XplorePlane.Tests/Pipeline/PipelineExecutionServiceTests.cs new file mode 100644 index 0000000..5fecc14 --- /dev/null +++ b/XplorePlane.Tests/Pipeline/PipelineExecutionServiceTests.cs @@ -0,0 +1,215 @@ +using Moq; +using Xunit; +using System.Windows.Media.Imaging; +using XplorePlane.Services; +using XplorePlane.ViewModels; +using XplorePlane.Tests.Helpers; +using System.Threading.Tasks; +using System; +using System.Linq; +using System.Collections.Generic; +using System.Threading; + +namespace XplorePlane.Tests.Pipeline +{ + /// + /// 测试 PipelineExecutionService(任务 6.6) + /// + public class PipelineExecutionServiceTests + { + private readonly Mock _mockImageSvc; + private readonly PipelineExecutionService _svc; + private readonly BitmapSource _testBitmap; + + public PipelineExecutionServiceTests() + { + _mockImageSvc = TestHelpers.CreateMockImageService(); + _svc = new PipelineExecutionService(_mockImageSvc.Object); + _testBitmap = TestHelpers.CreateTestBitmap(); + } + + private static PipelineNodeViewModel MakeNode(string key, int order = 0, bool enabled = true) + { + var node = new PipelineNodeViewModel(key, key + "算子") { Order = order, IsEnabled = enabled }; + return node; + } + + // ── 空流水线 ────────────────────────────────────────────────── + + [Fact] + public async Task EmptyPipeline_ReturnsSourceImage() + { + var result = await _svc.ExecutePipelineAsync( + Enumerable.Empty(), _testBitmap); + + Assert.Same(_testBitmap, result); + } + + [Fact] + public async Task NullNodes_ReturnsSourceImage() + { + var result = await _svc.ExecutePipelineAsync(null!, _testBitmap); + + Assert.Same(_testBitmap, result); + } + + [Fact] + public async Task AllDisabledNodes_ReturnsSourceImage() + { + var nodes = new[] { MakeNode("Blur", 0, enabled: false) }; + var result = await _svc.ExecutePipelineAsync(nodes, _testBitmap); + + Assert.Same(_testBitmap, result); + } + + [Fact] + public async Task NullSource_ThrowsArgumentNullException() + { + await Assert.ThrowsAsync( + () => _svc.ExecutePipelineAsync(Enumerable.Empty(), null!)); + } + + // ── 取消令牌 ────────────────────────────────────────────────── + + [Fact] + public async Task CancelledToken_ThrowsOperationCanceledException() + { + // 让 ProcessImageAsync 在执行时检查取消令牌 + _mockImageSvc.Setup(s => s.ProcessImageAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny>(), + It.IsAny())) + .Returns, IProgress, CancellationToken>( + (src, _, _, _, ct) => + { + ct.ThrowIfCancellationRequested(); + return Task.FromResult(src); + }); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var nodes = new[] { MakeNode("Blur", 0) }; + + await Assert.ThrowsAsync( + () => _svc.ExecutePipelineAsync(nodes, _testBitmap, cancellationToken: cts.Token)); + } + + [Fact] + public async Task PreCancelledToken_ThrowsBeforeExecution() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var nodes = new[] { MakeNode("Blur", 0) }; + + await Assert.ThrowsAsync( + () => _svc.ExecutePipelineAsync(nodes, _testBitmap, cancellationToken: cts.Token)); + + // ProcessImageAsync 不应被调用 + _mockImageSvc.Verify(s => s.ProcessImageAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny>(), + It.IsAny()), Times.Never); + } + + // ── 节点失败包装 ────────────────────────────────────────────── + + [Fact] + public async Task NodeThrows_WrappedAsPipelineExecutionException() + { + _mockImageSvc.Setup(s => s.ProcessImageAsync( + It.IsAny(), + "Blur", + It.IsAny>(), + It.IsAny>(), + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("处理失败")); + + var nodes = new[] { MakeNode("Blur", 0) }; + + var ex = await Assert.ThrowsAsync( + () => _svc.ExecutePipelineAsync(nodes, _testBitmap)); + + Assert.Equal("Blur", ex.FailedOperatorKey); + Assert.Equal(0, ex.FailedNodeOrder); + } + + [Fact] + public async Task NodeReturnsNull_ThrowsPipelineExecutionException() + { + _mockImageSvc.Setup(s => s.ProcessImageAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync((BitmapSource?)null); + + var nodes = new[] { MakeNode("Blur", 0) }; + + await Assert.ThrowsAsync( + () => _svc.ExecutePipelineAsync(nodes, _testBitmap)); + } + + // ── 进度回调 ────────────────────────────────────────────────── + + [Fact] + public async Task Progress_ReportedForEachNode() + { + var nodes = new[] + { + MakeNode("Blur", 0), + MakeNode("Sharpen", 1) + }; + + var reports = new List(); + var progress = new Progress(p => reports.Add(p)); + + await _svc.ExecutePipelineAsync(nodes, _testBitmap, progress); + + // 等待 Progress 回调(Progress 是异步的) + await Task.Delay(50); + + Assert.Equal(2, reports.Count); + Assert.Equal(1, reports[0].CurrentStep); + Assert.Equal(2, reports[1].CurrentStep); + } + + // ── 节点按 Order 排序执行 ───────────────────────────────────── + + [Fact] + public async Task Nodes_ExecutedInOrderAscending() + { + var executionOrder = new List(); + _mockImageSvc.Setup(s => s.ProcessImageAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny>(), + It.IsAny())) + .Returns, IProgress, CancellationToken>( + (src, key, _, _, _) => + { + executionOrder.Add(key); + return Task.FromResult(src); + }); + + // 故意乱序传入 + var nodes = new[] + { + MakeNode("Threshold", 2), + MakeNode("Blur", 0), + MakeNode("Sharpen", 1) + }; + + await _svc.ExecutePipelineAsync(nodes, _testBitmap); + + Assert.Equal(new[] { "Blur", "Sharpen", "Threshold" }, executionOrder); + } + } +} diff --git a/XplorePlane.Tests/Pipeline/PipelinePersistenceServiceTests.cs b/XplorePlane.Tests/Pipeline/PipelinePersistenceServiceTests.cs new file mode 100644 index 0000000..47a4605 --- /dev/null +++ b/XplorePlane.Tests/Pipeline/PipelinePersistenceServiceTests.cs @@ -0,0 +1,203 @@ +using Xunit; +using XplorePlane.Models; +using XplorePlane.Services; +using XplorePlane.Tests.Helpers; +using System.Threading.Tasks; +using System.IO; +using System; + +namespace XplorePlane.Tests.Pipeline +{ + /// + /// 测试 PipelinePersistenceService(任务 6.5) + /// + public class PipelinePersistenceServiceTests : IDisposable + { + private readonly string _tempDir; + private readonly PipelinePersistenceService _svc; + + public PipelinePersistenceServiceTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"XplorePlaneTests_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + + var mockImageSvc = TestHelpers.CreateMockImageService(new[] { "Blur", "Sharpen" }); + _svc = new PipelinePersistenceService(mockImageSvc.Object, _tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, recursive: true); + } + + private static PipelineModel BuildModel(string name = "TestPipeline", params string[] keys) + { + var model = new PipelineModel { Name = name }; + for (int i = 0; i < keys.Length; i++) + model.Nodes.Add(new PipelineNodeModel { OperatorKey = keys[i], Order = i }); + return model; + } + + // ── 往返一致性 ──────────────────────────────────────────────── + + [Fact] + public async Task SaveAndLoad_RoundTrip_NamePreserved() + { + var model = BuildModel("MyPipeline", "Blur", "Sharpen"); + var path = Path.Combine(_tempDir, "test.pipeline.json"); + + await _svc.SaveAsync(model, path); + var loaded = await _svc.LoadAsync(path); + + Assert.Equal("MyPipeline", loaded.Name); + } + + [Fact] + public async Task SaveAndLoad_RoundTrip_NodeCountPreserved() + { + var model = BuildModel("P", "Blur", "Sharpen"); + var path = Path.Combine(_tempDir, "test.pipeline.json"); + + await _svc.SaveAsync(model, path); + var loaded = await _svc.LoadAsync(path); + + Assert.Equal(2, loaded.Nodes.Count); + } + + [Fact] + public async Task SaveAndLoad_RoundTrip_OperatorKeysPreserved() + { + var model = BuildModel("P", "Blur", "Sharpen"); + var path = Path.Combine(_tempDir, "test.pipeline.json"); + + await _svc.SaveAsync(model, path); + var loaded = await _svc.LoadAsync(path); + + Assert.Equal("Blur", loaded.Nodes[0].OperatorKey); + Assert.Equal("Sharpen", loaded.Nodes[1].OperatorKey); + } + + [Fact] + public async Task SaveAndLoad_RoundTrip_OrderIsSequential() + { + var model = BuildModel("P", "Blur", "Sharpen"); + var path = Path.Combine(_tempDir, "test.pipeline.json"); + + await _svc.SaveAsync(model, path); + var loaded = await _svc.LoadAsync(path); + + for (int i = 0; i < loaded.Nodes.Count; i++) + Assert.Equal(i, loaded.Nodes[i].Order); + } + + // ── 非法路径被拒绝 ──────────────────────────────────────────── + + [Fact] + public async Task SaveAsync_PathTraversal_ThrowsUnauthorized() + { + var model = BuildModel("P"); + await Assert.ThrowsAsync( + () => _svc.SaveAsync(model, "../../../evil.json")); + } + + [Fact] + public async Task LoadAsync_PathTraversal_ThrowsUnauthorized() + { + await Assert.ThrowsAsync( + () => _svc.LoadAsync("../../etc/passwd")); + } + + [Fact] + public async Task LoadAsync_EmptyPath_ThrowsArgumentException() + { + await Assert.ThrowsAsync( + () => _svc.LoadAsync(string.Empty)); + } + + [Fact] + public async Task LoadAsync_NullPath_ThrowsArgumentException() + { + await Assert.ThrowsAsync( + () => _svc.LoadAsync(null!)); + } + + [Fact] + public async Task LoadAsync_FileNotFound_ThrowsFileNotFoundException() + { + var path = Path.Combine(_tempDir, "nonexistent.pipeline.json"); + await Assert.ThrowsAsync( + () => _svc.LoadAsync(path)); + } + + // ── 非法 OperatorKey 被跳过 ─────────────────────────────────── + + [Fact] + public async Task Load_UnregisteredOperatorKey_NodeSkipped() + { + // 手动写入包含未注册 Key 的 JSON + var json = """ + { + "Id": "00000000-0000-0000-0000-000000000001", + "Name": "Test", + "DeviceId": "", + "Nodes": [ + { "Id": "00000000-0000-0000-0000-000000000002", "OperatorKey": "Blur", "Order": 0, "IsEnabled": true, "Parameters": {} }, + { "Id": "00000000-0000-0000-0000-000000000003", "OperatorKey": "UNKNOWN_KEY", "Order": 1, "IsEnabled": true, "Parameters": {} } + ] + } + """; + var path = Path.Combine(_tempDir, "mixed.pipeline.json"); + await File.WriteAllTextAsync(path, json); + + var loaded = await _svc.LoadAsync(path); + + Assert.Single(loaded.Nodes); + Assert.Equal("Blur", loaded.Nodes[0].OperatorKey); + } + + [Fact] + public async Task Load_AllUnregisteredKeys_ReturnsEmptyNodes() + { + var json = """ + { + "Id": "00000000-0000-0000-0000-000000000001", + "Name": "Test", + "DeviceId": "", + "Nodes": [ + { "Id": "00000000-0000-0000-0000-000000000002", "OperatorKey": "FAKE1", "Order": 0, "IsEnabled": true, "Parameters": {} }, + { "Id": "00000000-0000-0000-0000-000000000003", "OperatorKey": "FAKE2", "Order": 1, "IsEnabled": true, "Parameters": {} } + ] + } + """; + var path = Path.Combine(_tempDir, "all_fake.pipeline.json"); + await File.WriteAllTextAsync(path, json); + + var loaded = await _svc.LoadAsync(path); + + Assert.Empty(loaded.Nodes); + } + + // ── LoadAllAsync ────────────────────────────────────────────── + + [Fact] + public async Task LoadAllAsync_EmptyDirectory_ReturnsEmpty() + { + var result = await _svc.LoadAllAsync(_tempDir); + Assert.Empty(result); + } + + [Fact] + public async Task LoadAllAsync_MultipleFiles_ReturnsAll() + { + var m1 = BuildModel("P1", "Blur"); + var m2 = BuildModel("P2", "Sharpen"); + await _svc.SaveAsync(m1, Path.Combine(_tempDir, "p1.pipeline.json")); + await _svc.SaveAsync(m2, Path.Combine(_tempDir, "p2.pipeline.json")); + + var result = await _svc.LoadAllAsync(_tempDir); + + Assert.Equal(2, result.Count); + } + } +} diff --git a/XplorePlane.Tests/Pipeline/PipelinePropertyTests.cs b/XplorePlane.Tests/Pipeline/PipelinePropertyTests.cs new file mode 100644 index 0000000..ce4fad8 --- /dev/null +++ b/XplorePlane.Tests/Pipeline/PipelinePropertyTests.cs @@ -0,0 +1,278 @@ +using FsCheck; +using FsCheck.Fluent; +using FsCheck.Xunit; +using Moq; +using Xunit; +using System; +using System.IO; +using System.Linq; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using XplorePlane.Models; +using XplorePlane.Services; +using XplorePlane.ViewModels; +using XplorePlane.Tests.Helpers; + +namespace XplorePlane.Tests.Pipeline +{ + /// + /// 属性测试(Property-Based Tests)使用 FsCheck + /// 覆盖任务 7.1 ~ 7.7 + /// + public class PipelinePropertyTests + { + private static readonly string[] Keys = TestHelpers.DefaultKeys; + + private PipelineEditorViewModel CreateVm() + { + var mockImageSvc = TestHelpers.CreateMockImageService(); + var mockExecSvc = new Mock(); + var mockPersistSvc = new Mock(); + return new PipelineEditorViewModel(mockImageSvc.Object, mockExecSvc.Object, mockPersistSvc.Object); + } + + /// + /// 向 VM 中添加 n 个节点(循环使用 Keys) + /// + private static void PopulateNodes(PipelineEditorViewModel vm, int count) + { + for (int i = 0; i < count; i++) + vm.AddOperatorCommand.Execute(Keys[i % Keys.Length]); + } + + // ── Property 6:ReorderNode 后 Order == 下标 ────────────────── + + /// + /// 任务 7.1:对任意合法 oldIndex/newIndex,ReorderNode 后所有 Order == 下标 + /// + [Property(MaxTest = 200)] + public Property ReorderNode_OrderEqualsIndex() + { + return Prop.ForAll( + Arb.From(Gen.Choose(2, 8)), // nodeCount: 2..8 + Arb.From(Gen.Choose(0, 7)), // oldIndex (clamped below) + Arb.From(Gen.Choose(0, 7)), // newIndex (clamped below) + (nodeCount, rawOld, rawNew) => + { + var vm = CreateVm(); + PopulateNodes(vm, nodeCount); + + int oldIdx = rawOld % nodeCount; + int newIdx = rawNew % nodeCount; + + vm.ReorderOperatorCommand.Execute(new PipelineReorderArgs + { + OldIndex = oldIdx, + NewIndex = newIdx + }); + + return vm.PipelineNodes + .Select((n, i) => n.Order == i) + .All(x => x); + }); + } + + // ── Property 7:RemoveOperator 后节点不存在且 Order 连续 ─────── + + /// + /// 任务 7.2:对任意节点,RemoveOperator 后节点不存在且剩余 Order 连续 + /// + [Property(MaxTest = 200)] + public Property RemoveOperator_NodeAbsentAndOrderContinuous() + { + return Prop.ForAll( + Arb.From(Gen.Choose(1, 8)), + Arb.From(Gen.Choose(0, 7)), + (nodeCount, rawIdx) => + { + var vm = CreateVm(); + PopulateNodes(vm, nodeCount); + + int idx = rawIdx % nodeCount; + var target = vm.PipelineNodes[idx]; + + vm.RemoveOperatorCommand.Execute(target); + + bool notPresent = !vm.PipelineNodes.Contains(target); + bool orderContinuous = vm.PipelineNodes + .Select((n, i) => n.Order == i) + .All(x => x); + + return notPresent && orderContinuous; + }); + } + + // ── Property 3:AddOperator 后末尾节点正确 ──────────────────── + + /// + /// 任务 7.3:对任意已注册 Key,AddOperator 后末尾节点 OperatorKey 和 Order 正确 + /// + [Property(MaxTest = 200)] + public Property AddOperator_LastNodeCorrect() + { + return Prop.ForAll( + Arb.From(Gen.Elements(Keys)), // 随机选一个合法 Key + Arb.From(Gen.Choose(0, 5)), // 已有节点数 + (key, existingCount) => + { + var vm = CreateVm(); + PopulateNodes(vm, existingCount); + + int expectedOrder = vm.PipelineNodes.Count; + vm.AddOperatorCommand.Execute(key); + + var last = vm.PipelineNodes.LastOrDefault(); + return last != null + && last.OperatorKey == key + && last.Order == expectedOrder; + }); + } + + // ── Property 5:未注册 Key 不改变节点数 ────────────────────── + + /// + /// 任务 7.4:对任意未注册 Key,AddOperator 后 PipelineNodes 长度不变 + /// + [Property(MaxTest = 200)] + public Property AddOperator_UnregisteredKey_LengthUnchanged() + { + return Prop.ForAll( + Arb.From(Gen.Choose(0, 5)), + (existingCount) => + { + var vm = CreateVm(); + PopulateNodes(vm, existingCount); + + int before = vm.PipelineNodes.Count; + vm.AddOperatorCommand.Execute("UNREGISTERED_KEY_XYZ"); + + return vm.PipelineNodes.Count == before; + }); + } + + // ── Property 14:Save/Load 往返一致性 ──────────────────────── + + /// + /// 任务 7.5:对任意合法 PipelineModel,Save 后 Load 得到字段一致的对象 + /// + [Property(MaxTest = 100)] + public Property SaveLoad_RoundTrip_FieldsConsistent() + { + return Prop.ForAll( + Arb.From(Gen.Choose(0, 5)), + (nodeCount) => + { + var tempDir = Path.Combine(Path.GetTempPath(), $"PBT_{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + + try + { + var mockImageSvc = TestHelpers.CreateMockImageService(new[] { "Blur", "Sharpen" }); + var svc = new PipelinePersistenceService(mockImageSvc.Object, tempDir); + + var model = new PipelineModel { Name = $"Pipeline_{nodeCount}" }; + for (int i = 0; i < nodeCount; i++) + model.Nodes.Add(new PipelineNodeModel + { + OperatorKey = i % 2 == 0 ? "Blur" : "Sharpen", + Order = i + }); + + var path = Path.Combine(tempDir, "test.pipeline.json"); + svc.SaveAsync(model, path).GetAwaiter().GetResult(); + var loaded = svc.LoadAsync(path).GetAwaiter().GetResult(); + + return loaded.Name == model.Name + && loaded.Nodes.Count == model.Nodes.Count + && loaded.Nodes.Select((n, i) => n.Order == i).All(x => x); + } + finally + { + if (Directory.Exists(tempDir)) + Directory.Delete(tempDir, recursive: true); + } + }); + } + + // ── Property 12:取消令牌触发 OperationCanceledException ────── + + /// + /// 任务 7.6:对任意流水线,取消令牌触发后抛出 OperationCanceledException + /// + [Property(MaxTest = 100)] + public Property CancelToken_AlwaysThrowsOperationCanceled() + { + return Prop.ForAll( + Arb.From(Gen.Choose(1, 5)), + (nodeCount) => + { + var mockImageSvc = TestHelpers.CreateMockImageService(); + mockImageSvc.Setup(s => s.ProcessImageAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny>(), + It.IsAny())) + .Returns, IProgress, CancellationToken>( + (src, _, _, _, ct) => + { + ct.ThrowIfCancellationRequested(); + return Task.FromResult(src); + }); + + var svc = new PipelineExecutionService(mockImageSvc.Object); + var bitmap = TestHelpers.CreateTestBitmap(); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var nodes = Enumerable.Range(0, nodeCount) + .Select(i => new PipelineNodeViewModel(Keys[i % Keys.Length], "N") { Order = i }) + .ToList(); + + try + { + svc.ExecutePipelineAsync(nodes, bitmap, cancellationToken: cts.Token) + .GetAwaiter().GetResult(); + return false; // 应该抛出异常 + } + catch (OperationCanceledException) + { + return true; + } + catch + { + return false; + } + }); + } + + // ── Property 2:FilteredOperators 每项均包含搜索文本 ───────── + + /// + /// 任务 7.7:对任意搜索文本,FilteredOperators 中每个算子 DisplayName 均包含该文本 + /// + [Property(MaxTest = 200)] + public Property FilteredOperators_AllContainSearchText() + { + // 生成非空、非空白的搜索字符串(限制长度避免无意义测试) + var searchGen = Gen.Elements("Blur", "Sharpen", "Threshold", "Denoise", "Contrast", + "bl", "sh", "th", "de", "co", "a", "e", "r"); + + return Prop.ForAll( + Arb.From(searchGen), + (searchText) => + { + var mockImageSvc = TestHelpers.CreateMockImageService( + new[] { "Blur", "Sharpen", "Threshold", "Denoise", "Contrast" }); + var vm = new OperatorToolboxViewModel(mockImageSvc.Object); + vm.SearchText = searchText; + + return vm.FilteredOperators.All(op => + op.DisplayName.Contains(searchText, StringComparison.OrdinalIgnoreCase)); + }); + } + } +} diff --git a/XplorePlane.Tests/XplorePlane.Tests.csproj b/XplorePlane.Tests/XplorePlane.Tests.csproj new file mode 100644 index 0000000..b2c1ace --- /dev/null +++ b/XplorePlane.Tests/XplorePlane.Tests.csproj @@ -0,0 +1,33 @@ + + + net8.0-windows + true + XplorePlane.Tests + false + enable + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + ..\XplorePlane\Libs\ImageProcessing\ImageProcessing.Core.dll + True + + + diff --git a/XplorePlane/Libs/Hardware/en-US/XP.Common.resources.dll b/XplorePlane/Libs/Hardware/en-US/XP.Common.resources.dll new file mode 100644 index 0000000000000000000000000000000000000000..479b022bf00a38b65ddc7b49b2431937efc4cc4c GIT binary patch literal 10240 zcmeHMdvqMtdB0;l{FH1NgC${LY}rPZwYCJ3rD$bIK#s*|_O7&ec4nEG zmAs~KeA>7PF^^COXdr~NCV|kJG_AH)swTS!qZ+ihQz2ayodwTAx z%b%6HUtK;h9<$_xX^fdlTviobH*9&7%VtuSW4heY8@n4xcK2n6X@E4L>#J8AMwI#1fBqSeZM$x$t)UdK}d#xAaJ8vWF#lnv^J z#=7SE1|Do`uF7vLsoSwQcUup0oAjcjJJQ$MLH}#IoA>@t>(S3$ySw7^KRbBzj;5#9 z?_cv~^M0de^8vRq>KisZQJo?mEQ{VW<$=XBD^c5X0 z{qo(1cRuyu;U9jl=ehV7LNC1WGx?>RC)Af7TV46`H*dZC*zod=$7b#O)ptI+>gFFj zas2()-Z=Q>6Z_tM_?JzW?LFD_%foNKwX^J0>Vu=FzEJk=sh)}tf7Q9=;on|*Tm9)d zHQP_ywSjjFYu3K^*Jo4j|McleXnkN>Xt(Af`*KYHia^X@$Sc*)LJ z=a-gLEL!+qi~hK9%M)c~iTl=GRI~KXi^e*ySo%iln-wKX@2xcd)?D#?`~O~fxVNtQ z`2z=Q7kqT$s{J=Fyn05l(B8FUo)J%b1v}lz7()O;rT7V z-T;q&INsIB8IPjuK>+>~&wBv>*CNL10Xu_I0opm>v2-D0U7-7CwEH%2zl*y2P*mVr zpqr29Hq^HP?>i_P@f{?eMmdYJ6?7x0KZf_3*^J$e_ane>giQZ}@+|5)kb6?VdmK3B zX!|7UCm?4C6IkfRhb{tal4122WL6uj<)EN|iY zCU8%Ib_v?8hTg76`8Sj*wEHajJqyp(c%BCSBjCRn`0s(=GvIdz=)VMcOu*@?S^{`0 zOW-%c23U;Q=qeYWC2cH;Pn#J`2e6ez@LL5?c`kscHI`yE09JZ|?Fc-bsbHzGx_sJp zR2$3!xH+G;Lr`%@z*!5xmv~u+AP&jf3D9CMsFz*GwxfEPx4IvUqRhnIV&kkI2svFL z1QNz(H|Dn&Mpq^P90e$+mtj;ZV61}eIKZ_eI*F~~ZU8XC+JzeRblp~<$AqRM&>dre z3lW523svaRf`oU1q#>WA13WDtk-^-Syo859xIUjS3^5gyB*@9qD!T>mF$f+5qcQd) zkS)t6%lOOBfv}pISA;>wraBxIT;hUzObB)wsO4VN5E?Rto<0U}r5D%*38Dg+&7}ao z42iNE96}=|kR6d10im)RRV05B&oQWegoW_E1EYV1KllciuN|(lVtViZ8)A(BFX;h8 z9jr-VqY#jS3ztOb&XK~SAb zKvtVTYZ`=zF#1Xe1w9GmKrV7GfE>0X7j$@M$H#Gy1TReDb@bo)wc~_0xw?s#=*x9; z4k1gB67xE~1Co>0DDWe2jd8q_N0Byf0>O$t){W}(#rM5HTj`JHmHBZX*0_k|+vktv zSAZUH(Wx^EBpei-qp%ni7WKAp5+sXVl1%iTLv_%t9)fbnXZZzqEx;(!(2s^v2q*G> zN`Dk$ls+hxknu?z4euOUB9X|=)`GCskFZ@ZF@dCmL583n1OY#S5Q_uZ6y~5oh9seU zn_z7q+3ru`rW!IC*-{6A4WT3KX0%TZJp?PK0~E}ugT25S0n4@vvm`yZUFZcv8^lrg zn(H?EXToL>GHD=nqScShx4@>_PZ|m}GGBHC{Rqf4=R@{-$sd63tM48NwAkT8$C1*Bw^^eH#BwiKNJpYqR5H8wh;U{{ZwK2Y+`!r-YcD{(C`qxu0z^QfkyA&hF{t&yeUIg3p7%S3i`iRJ4;sFEZiuz;yh*sX9aO2nc+WPPP-! zER1yJ&b9b}@8IN}6pvuB6a(2fQmLPnehoIhIUtt} z&HW}tT6a|?VbQuGH=&LQ4Q9{4F9zDx7e*UlG$p0^4^2iAB1UOdQw{RXGa;wgv_R%e z^|nJ?3UivE^nr!!jZ5#^jFRD(fVA3+um>_On2ds)iGqVe&pr(YZXlu3t&XUAmRvs*N2$+1baF3R)u(si-xP z&{9%yyP~UHlZx6k!{SnL2iLgGrNU4oW=peruajo?^Qg(Kaj6KqecYA`!^#Bk!dx-c zajCKc*s5)qvTEphx|ygF(|AO>A{WLZvXxY^S!qR+8rohlg%F8hw^GpO;>>MTZ%M|? zm_8;gnnG_kblWsEX#rlMF*B}E@1&xTX&R<9ze|rAZr$u5#ng#-c1R!7V(7-=a4eqC zV$m2{Y9Ej3=#+HPfT^IrV4`SKl|`+iMiTuE?S>`_x1vi@VJFuT(j07e+TdwPMFWPR zSyJI(j895(tI6e*kwhQUFO!OH%eEm$gig0e3n8wnBWT-})^zC;iWZAt7eA4-19G3n z6^qO2I9GSciXM?|GbJlyN=%m)^(MF}`YET?D`#p{16_h4a`gM@ZbctUDq~!R;YecH zg6$$x?ZB+IVnW1mE-UbcnCdhDk?Auf06L$P;Z1^1K)PyRoTGONN>UfxAT6NSfR;N9 z(S5hq1l=NC+}GXi;~SM()wyC{MzHBUqH<6%W6G$8^ABuwdp&ACk-FY-*%acW8MAG! zdvS|fRXeF@Qb~K#G&w9){n~S_Yotq^sByN{Wgh3|7}wR5Y}rOaszJ2S;foTvIceE) zLcxIy^ln=6%3i_=&;bY%j`gRJX@bJ11> z#}!gxR|Itnx?BZNOd}&Hk(4wKF&-!H-htS3B4cKaG>iNiOoJJ;7r2a={P_eP_R3rHo-x1Rni9#qt1GA=D}?Ojc_UFH#S z61_+!5GzhBOO<(0>SHny2n3|^&e+(vj02!4;Xp0&+~RIyQl1d4$!=ZQR34bhT&u&# zNvcf&YvN>)M>%33LQ$8{FlM*`FSSjPro&vGjA@!|E4#SNqfugkafK3yj)ON(77?BT zaizKGvZUb3ZAEOSI^qB??6zR{jrRQSoN!QE{J0poSmEi+J zT!ZpBW!xEzf|$&OuWggkCvrFvU=wseRv)adqkVEL1T@^=Mya|T=XKcYqH~6-uuWr; zMHu7Q(!ucntJ)D}3B2P-fK_eV(ba+HUVPIzWL2AHjN(Zbs-)RC3z!Q&?4won>=NfZ z5EDWn1wGw&LZO0_kMy$`=K>eu*tSjFZxnZ1+#kf9E(f1_G?#5;SFkd1uMqd;;=V@Q z18hE9$pWCF`=#Pu#p?0C7So}^y)GxP6xR>g$;>%0Y9xlk6;T|_-#BPE$mua6NsVu2 z?%5-oIyj7yw_$u`t(L{(qgrYpW^26FhBil&HfI5vLrekN5d(^z%79%ects?s+WCO2 zF<;Tr2cG#@G>GQ`hx-C(FK*~K>va!PBbvs1>KWJ9!PprHrbIc%uU0_dWOzY21+vi4 zvRD3URn;G_4L$kLH##4_<};sTbB^r!%wR>si941IT)A%9gO~mE#WU4E*;)MbxMFy>Tq;;1c9M~Ap;d6QbFDve6>hWe%m=Zz}o!Mgf}ru9lgQ$!6aYEW&i z4{m70(Y3^83z|TXv6s^4(j?F?=v}6_{L4MR&Ok=m4YNbjdN3Jr?8|w8)I`_0Rp3MJ z|Ch^G1CmfqInsg`>|`I9C9j_F=+JGr1!0A~LigHO(16!L>@1An9>S()7+Wm8I8LFh zmhIS{=oP5X7oPdViB2agxG}vY&8QGh5EwA!9?G`veocTJx25eeG2dn`(MZ%7;vtFk*Qs|K) z7CXd$80)e?8XgChj=GJ4#%;rYK|3975ZPOrl5Y@8DpRcX9L@}9sR7947K{O`9QdRW z+PSAa654prNSH5Q09dq!%xv9T;79vs6gSndfP~dMhn9#F^*n(-({h~l4`}?5QHDjk zz3F;dQ4+S>XZoV$pr3xu-@c=7XPYB}y%;)D$n?gKA7h_oS3zbkO|IrLdRq&*n!vXX zMPZF7&1?fknkMLg8^sR9?uQHr>jZWtrdE;KpPjQ hFezwidO663f94Af(wyR*tbF@tmf$bV{Xc5p{{To|B830| literal 0 HcmV?d00001 diff --git a/XplorePlane/Libs/Hardware/zh-CN/XP.Common.resources.dll b/XplorePlane/Libs/Hardware/zh-CN/XP.Common.resources.dll new file mode 100644 index 0000000000000000000000000000000000000000..ba2cede95b90a3758cfe84db075aa88119a7dde2 GIT binary patch literal 9216 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 + Background="Transparent"> diff --git a/XplorePlane/Views/OperatorToolboxView.xaml.cs b/XplorePlane/Views/OperatorToolboxView.xaml.cs index 53aff74..eea75e8 100644 --- a/XplorePlane/Views/OperatorToolboxView.xaml.cs +++ b/XplorePlane/Views/OperatorToolboxView.xaml.cs @@ -1,19 +1,50 @@ +using System.Windows; using System.Windows.Controls; using Prism.Ioc; +using Telerik.Windows.DragDrop; using XplorePlane.ViewModels; namespace XplorePlane.Views { public partial class OperatorToolboxView : UserControl { + private const string DragFormat = "OperatorDescriptor"; + public OperatorToolboxView() { InitializeComponent(); - Loaded += (_, _) => + Loaded += OnLoaded; + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + if (DataContext == null) + DataContext = ContainerLocator.Container.Resolve(); + + // 启用拖拽源 + 注册拖拽初始化事件 + DragDropManager.SetAllowDrag(ToolboxListBox, true); + DragDropManager.AddDragInitializeHandler(ToolboxListBox, OnDragInitialize, true); + } + + private void OnDragInitialize(object sender, DragInitializeEventArgs e) + { + if (ToolboxListBox.SelectedItem is OperatorDescriptor descriptor) { - if (DataContext == null) - DataContext = ContainerLocator.Container.Resolve(); - }; + e.AllowedEffects = System.Windows.DragDropEffects.Copy; + DragDropPayloadManager.SetData(e.Data, DragFormat, descriptor); + e.DragVisual = new System.Windows.Controls.TextBlock + { + Text = descriptor.DisplayName, + Padding = new Thickness(6, 3, 6, 3), + Background = System.Windows.Media.Brushes.LightBlue, + FontFamily = new System.Windows.Media.FontFamily("Microsoft YaHei UI"), + FontSize = 11 + }; + } + else + { + e.Cancel = true; + } } } } diff --git a/XplorePlane/Views/PipelineEditorView.xaml b/XplorePlane/Views/PipelineEditorView.xaml index 2749d26..50d0f19 100644 --- a/XplorePlane/Views/PipelineEditorView.xaml +++ b/XplorePlane/Views/PipelineEditorView.xaml @@ -87,7 +87,6 @@ SelectedItem="{Binding SelectedNode, Mode=TwoWay}" BorderThickness="0" Background="Transparent" - telerik:RadDragAndDropManager.AllowDrop="True" ItemContainerStyle="{StaticResource PipelineNodeItemStyle}"> diff --git a/XplorePlane/Views/PipelineEditorView.xaml.cs b/XplorePlane/Views/PipelineEditorView.xaml.cs index 20e50c6..ba9eb01 100644 --- a/XplorePlane/Views/PipelineEditorView.xaml.cs +++ b/XplorePlane/Views/PipelineEditorView.xaml.cs @@ -1,14 +1,15 @@ using System.Windows; using System.Windows.Controls; using Prism.Ioc; -using Telerik.Windows.Controls.DragDrop; -using XplorePlane.Models; +using Telerik.Windows.DragDrop; using XplorePlane.ViewModels; namespace XplorePlane.Views { public partial class PipelineEditorView : UserControl { + private const string DragFormat = "OperatorDescriptor"; + public PipelineEditorView() { InitializeComponent(); @@ -20,18 +21,20 @@ namespace XplorePlane.Views if (DataContext == null) DataContext = ContainerLocator.Container.Resolve(); - // 配置拖拽目标:从工具箱拖入算子 - RadDragAndDropManager.AddDropInfoHandler(PipelineListBox, OnOperatorDropped); + // 启用拖拽目标 + 注册 Drop 事件 + PipelineListBox.AllowDrop = true; + DragDropManager.AddDropHandler(PipelineListBox, OnOperatorDropped, true); } - private void OnOperatorDropped(object sender, DragDropEventArgs e) + private void OnOperatorDropped(object sender, Telerik.Windows.DragDrop.DragEventArgs e) { - if (DataContext is PipelineEditorViewModel vm - && e.Options.Payload is OperatorDescriptor descriptor) - { - vm.AddOperatorCommand.Execute(descriptor.Key); - e.Handled = true; - } + if (DataContext is not PipelineEditorViewModel vm) return; + + var descriptor = DragDropPayloadManager.GetDataFromObject(e.Data, DragFormat) as OperatorDescriptor; + if (descriptor == null) return; + + vm.AddOperatorCommand.Execute(descriptor.Key); + e.Handled = true; } } } diff --git a/XplorePlane/readme.txt b/XplorePlane/readme.txt index bd78553..6eee903 100644 --- a/XplorePlane/readme.txt +++ b/XplorePlane/readme.txt @@ -15,5 +15,5 @@ 1、主页面的布局与拆分 √ 2、硬件层射线源的集成 √ 3、图像层集成,包括复刻一个示例界面,优化界面布局及算子中文 √ -4、浮动图像处理工具箱调研 +4、浮动图像处理工具箱调研 √ 5、各窗体间数据流的传递,全局数据结构的设计 \ No newline at end of file