#0021 增加测试工程

This commit is contained in:
zhengxuan.zhang
2026-03-14 23:58:38 +08:00
parent 7c7d0b6a20
commit 13d140bcfe
15 changed files with 1235 additions and 19 deletions
+76
View File
@@ -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
{
/// <summary>
/// 测试辅助工具:创建 Mock 和测试用图像
/// </summary>
internal static class TestHelpers
{
public static readonly string[] DefaultKeys = { "Blur", "Sharpen", "Threshold" };
/// <summary>
/// 创建一个 1x1 像素的 BitmapSource,用于测试
/// </summary>
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;
}
/// <summary>
/// 创建一个超大图像(用于测试预览缩放)
/// </summary>
public static BitmapSource CreateLargeBitmap(int width = 4096, int height = 4096)
=> CreateTestBitmap(width, height);
/// <summary>
/// 创建标准 Mock IImageProcessingService,注册 DefaultKeys 中的算子
/// </summary>
public static Mock<IImageProcessingService> CreateMockImageService(
string[]? keys = null,
ProcessorParameter[]? parameters = null)
{
keys ??= DefaultKeys;
parameters ??= Array.Empty<ProcessorParameter>();
var mock = new Mock<IImageProcessingService>();
mock.Setup(s => s.GetAvailableProcessors()).Returns(keys);
mock.Setup(s => s.GetProcessorDisplayName(It.IsAny<string>()))
.Returns<string>(k => k + "算子");
mock.Setup(s => s.GetProcessorParameters(It.IsAny<string>()))
.Returns(parameters);
mock.Setup(s => s.ProcessImageAsync(
It.IsAny<BitmapSource>(),
It.IsAny<string>(),
It.IsAny<IDictionary<string, object>>(),
It.IsAny<IProgress<double>>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync((BitmapSource src, string _, IDictionary<string, object> _, IProgress<double> _, CancellationToken ct) =>
{
ct.ThrowIfCancellationRequested();
return src;
});
return mock;
}
/// <summary>
/// 创建带数值参数的 ProcessorParameter
/// </summary>
public static ProcessorParameter MakeIntParam(string name, int value = 5, int min = 0, int max = 10)
=> new ProcessorParameter(name, name, typeof(int), value, min, max);
}
}
@@ -0,0 +1,103 @@
using Xunit;
using XplorePlane.ViewModels;
using XplorePlane.Tests.Helpers;
using System;
namespace XplorePlane.Tests.Pipeline
{
/// <summary>
/// 测试 OperatorToolboxViewModel 搜索过滤逻辑(任务 6.7)
/// </summary>
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<string>());
vm.SearchText = string.Empty;
Assert.Empty(vm.FilteredOperators);
}
}
}
@@ -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
{
/// <summary>
/// 测试 PipelineEditorViewModel 的命令逻辑
/// 覆盖任务 6.1 / 6.2 / 6.3 / 6.4 / 6.8
/// </summary>
public class PipelineEditorViewModelTests
{
private readonly Mock<IImageProcessingService> _mockImageSvc;
private readonly Mock<IPipelineExecutionService> _mockExecSvc;
private readonly Mock<IPipelinePersistenceService> _mockPersistSvc;
public PipelineEditorViewModelTests()
{
_mockImageSvc = TestHelpers.CreateMockImageService();
_mockExecSvc = new Mock<IPipelineExecutionService>();
_mockPersistSvc = new Mock<IPipelinePersistenceService>();
}
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);
}
}
}
@@ -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
{
/// <summary>
/// 测试 PipelineExecutionService(任务 6.6
/// </summary>
public class PipelineExecutionServiceTests
{
private readonly Mock<IImageProcessingService> _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<PipelineNodeViewModel>(), _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<ArgumentNullException>(
() => _svc.ExecutePipelineAsync(Enumerable.Empty<PipelineNodeViewModel>(), null!));
}
// ── 取消令牌 ──────────────────────────────────────────────────
[Fact]
public async Task CancelledToken_ThrowsOperationCanceledException()
{
// 让 ProcessImageAsync 在执行时检查取消令牌
_mockImageSvc.Setup(s => s.ProcessImageAsync(
It.IsAny<BitmapSource>(),
It.IsAny<string>(),
It.IsAny<IDictionary<string, object>>(),
It.IsAny<IProgress<double>>(),
It.IsAny<CancellationToken>()))
.Returns<BitmapSource, string, IDictionary<string, object>, IProgress<double>, 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<OperationCanceledException>(
() => _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<OperationCanceledException>(
() => _svc.ExecutePipelineAsync(nodes, _testBitmap, cancellationToken: cts.Token));
// ProcessImageAsync 不应被调用
_mockImageSvc.Verify(s => s.ProcessImageAsync(
It.IsAny<BitmapSource>(),
It.IsAny<string>(),
It.IsAny<IDictionary<string, object>>(),
It.IsAny<IProgress<double>>(),
It.IsAny<CancellationToken>()), Times.Never);
}
// ── 节点失败包装 ──────────────────────────────────────────────
[Fact]
public async Task NodeThrows_WrappedAsPipelineExecutionException()
{
_mockImageSvc.Setup(s => s.ProcessImageAsync(
It.IsAny<BitmapSource>(),
"Blur",
It.IsAny<IDictionary<string, object>>(),
It.IsAny<IProgress<double>>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("处理失败"));
var nodes = new[] { MakeNode("Blur", 0) };
var ex = await Assert.ThrowsAsync<PipelineExecutionException>(
() => _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<BitmapSource>(),
It.IsAny<string>(),
It.IsAny<IDictionary<string, object>>(),
It.IsAny<IProgress<double>>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync((BitmapSource?)null);
var nodes = new[] { MakeNode("Blur", 0) };
await Assert.ThrowsAsync<PipelineExecutionException>(
() => _svc.ExecutePipelineAsync(nodes, _testBitmap));
}
// ── 进度回调 ──────────────────────────────────────────────────
[Fact]
public async Task Progress_ReportedForEachNode()
{
var nodes = new[]
{
MakeNode("Blur", 0),
MakeNode("Sharpen", 1)
};
var reports = new List<PipelineProgress>();
var progress = new Progress<PipelineProgress>(p => reports.Add(p));
await _svc.ExecutePipelineAsync(nodes, _testBitmap, progress);
// 等待 Progress 回调(Progress<T> 是异步的)
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<string>();
_mockImageSvc.Setup(s => s.ProcessImageAsync(
It.IsAny<BitmapSource>(),
It.IsAny<string>(),
It.IsAny<IDictionary<string, object>>(),
It.IsAny<IProgress<double>>(),
It.IsAny<CancellationToken>()))
.Returns<BitmapSource, string, IDictionary<string, object>, IProgress<double>, 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);
}
}
}
@@ -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
{
/// <summary>
/// 测试 PipelinePersistenceService(任务 6.5
/// </summary>
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<UnauthorizedAccessException>(
() => _svc.SaveAsync(model, "../../../evil.json"));
}
[Fact]
public async Task LoadAsync_PathTraversal_ThrowsUnauthorized()
{
await Assert.ThrowsAsync<UnauthorizedAccessException>(
() => _svc.LoadAsync("../../etc/passwd"));
}
[Fact]
public async Task LoadAsync_EmptyPath_ThrowsArgumentException()
{
await Assert.ThrowsAsync<ArgumentException>(
() => _svc.LoadAsync(string.Empty));
}
[Fact]
public async Task LoadAsync_NullPath_ThrowsArgumentException()
{
await Assert.ThrowsAsync<ArgumentException>(
() => _svc.LoadAsync(null!));
}
[Fact]
public async Task LoadAsync_FileNotFound_ThrowsFileNotFoundException()
{
var path = Path.Combine(_tempDir, "nonexistent.pipeline.json");
await Assert.ThrowsAsync<FileNotFoundException>(
() => _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);
}
}
}
@@ -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
{
/// <summary>
/// 属性测试(Property-Based Tests)使用 FsCheck
/// 覆盖任务 7.1 ~ 7.7
/// </summary>
public class PipelinePropertyTests
{
private static readonly string[] Keys = TestHelpers.DefaultKeys;
private PipelineEditorViewModel CreateVm()
{
var mockImageSvc = TestHelpers.CreateMockImageService();
var mockExecSvc = new Mock<IPipelineExecutionService>();
var mockPersistSvc = new Mock<IPipelinePersistenceService>();
return new PipelineEditorViewModel(mockImageSvc.Object, mockExecSvc.Object, mockPersistSvc.Object);
}
/// <summary>
/// 向 VM 中添加 n 个节点(循环使用 Keys)
/// </summary>
private static void PopulateNodes(PipelineEditorViewModel vm, int count)
{
for (int i = 0; i < count; i++)
vm.AddOperatorCommand.Execute(Keys[i % Keys.Length]);
}
// ── Property 6ReorderNode 后 Order == 下标 ──────────────────
/// <summary>
/// 任务 7.1:对任意合法 oldIndex/newIndexReorderNode 后所有 Order == 下标
/// </summary>
[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 7RemoveOperator 后节点不存在且 Order 连续 ───────
/// <summary>
/// 任务 7.2:对任意节点,RemoveOperator 后节点不存在且剩余 Order 连续
/// </summary>
[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 3AddOperator 后末尾节点正确 ────────────────────
/// <summary>
/// 任务 7.3:对任意已注册 KeyAddOperator 后末尾节点 OperatorKey 和 Order 正确
/// </summary>
[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 不改变节点数 ──────────────────────
/// <summary>
/// 任务 7.4:对任意未注册 KeyAddOperator 后 PipelineNodes 长度不变
/// </summary>
[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 14Save/Load 往返一致性 ────────────────────────
/// <summary>
/// 任务 7.5:对任意合法 PipelineModelSave 后 Load 得到字段一致的对象
/// </summary>
[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 ──────
/// <summary>
/// 任务 7.6:对任意流水线,取消令牌触发后抛出 OperationCanceledException
/// </summary>
[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<System.Windows.Media.Imaging.BitmapSource>(),
It.IsAny<string>(),
It.IsAny<IDictionary<string, object>>(),
It.IsAny<IProgress<double>>(),
It.IsAny<CancellationToken>()))
.Returns<System.Windows.Media.Imaging.BitmapSource, string,
IDictionary<string, object>, IProgress<double>, 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 2FilteredOperators 每项均包含搜索文本 ─────────
/// <summary>
/// 任务 7.7:对任意搜索文本,FilteredOperators 中每个算子 DisplayName 均包含该文本
/// </summary>
[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));
});
}
}
}
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<RootNamespace>XplorePlane.Tests</RootNamespace>
<IsPackable>false</IsPackable>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FsCheck" Version="3.1.0" />
<PackageReference Include="FsCheck.Xunit" Version="3.1.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\XplorePlane\XplorePlane.csproj" />
</ItemGroup>
<!-- 直接引用主项目依赖的本地 DLL -->
<ItemGroup>
<Reference Include="ImageProcessing.Core">
<HintPath>..\XplorePlane\Libs\ImageProcessing\ImageProcessing.Core.dll</HintPath>
<Private>True</Private>
</Reference>
</ItemGroup>
</Project>