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 0000000..479b022
Binary files /dev/null and b/XplorePlane/Libs/Hardware/en-US/XP.Common.resources.dll differ
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 0000000..ba2cede
Binary files /dev/null and b/XplorePlane/Libs/Hardware/zh-CN/XP.Common.resources.dll differ
diff --git a/XplorePlane/Libs/Hardware/zh-TW/XP.Common.resources.dll b/XplorePlane/Libs/Hardware/zh-TW/XP.Common.resources.dll
new file mode 100644
index 0000000..1b716cd
Binary files /dev/null and b/XplorePlane/Libs/Hardware/zh-TW/XP.Common.resources.dll differ
diff --git a/XplorePlane/Views/OperatorToolboxView.xaml b/XplorePlane/Views/OperatorToolboxView.xaml
index 6e2e742..1143c01 100644
--- a/XplorePlane/Views/OperatorToolboxView.xaml
+++ b/XplorePlane/Views/OperatorToolboxView.xaml
@@ -60,10 +60,10 @@
+ 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