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