279 lines
11 KiB
C#
279 lines
11 KiB
C#
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 6:ReorderNode 后 Order == 下标 ──────────────────
|
||
|
||
/// <summary>
|
||
/// 任务 7.1:对任意合法 oldIndex/newIndex,ReorderNode 后所有 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 7:RemoveOperator 后节点不存在且 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 3:AddOperator 后末尾节点正确 ────────────────────
|
||
|
||
/// <summary>
|
||
/// 任务 7.3:对任意已注册 Key,AddOperator 后末尾节点 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:对任意未注册 Key,AddOperator 后 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 14:Save/Load 往返一致性 ────────────────────────
|
||
|
||
/// <summary>
|
||
/// 任务 7.5:对任意合法 PipelineModel,Save 后 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 2:FilteredOperators 每项均包含搜索文本 ─────────
|
||
|
||
/// <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));
|
||
});
|
||
}
|
||
}
|
||
}
|