Files
XplorePlane/XplorePlane.Tests/Services/ImagePassingPropertyTests.cs
T
2026-05-18 15:32:00 +08:00

344 lines
15 KiB
C#

#pragma warning disable xUnit1031 // FsCheck property tests require synchronous execution via GetAwaiter().GetResult()
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using FsCheck;
using FsCheck.Fluent;
using FsCheck.Xunit;
using Moq;
using Prism.Events;
using XP.Common.Logging.Interfaces;
using XplorePlane.Events;
using XplorePlane.Models;
using XplorePlane.Services;
using XplorePlane.Services.AppState;
using XplorePlane.Services.Cnc;
using XplorePlane.Services.InspectionResults;
using XplorePlane.Services.MainViewport;
using XplorePlane.ViewModels;
using Xunit;
namespace XplorePlane.Tests.Services
{
/// <summary>
/// Property 6: 采集图像传递给紧邻的 InspectionModuleNode
/// Feature: cnc-multi-position-image-capture
/// **Validates: Requirements 3.2**
///
/// For any CNC program where a SavePositionNode is immediately followed (by Index order)
/// by an InspectionModuleNode, the image acquired at that SavePositionNode SHALL be passed
/// as the source image to the InspectionModuleNode's pipeline execution.
/// </summary>
public class ImagePassingPropertyTests
{
// ── Helper: Create a 1x1 BitmapSource for testing ──────────────────────
private static BitmapSource CreateTestBitmap()
{
var bitmap = new WriteableBitmap(1, 1, 96, 96, PixelFormats.Bgr32, null);
bitmap.Freeze();
return bitmap;
}
// ── Generator: CNC programs with SavePositionNodes optionally followed by InspectionModuleNodes ──
private static Gen<string> NonEmptyStringGen =>
ArbMap.Default.GeneratorFor<NonEmptyString>().Select(s => s.Get);
private static Gen<PipelineModel> PipelineGen =>
from name in NonEmptyStringGen
select new PipelineModel
{
Name = name,
Nodes = new List<PipelineNodeModel>
{
new PipelineNodeModel { OperatorKey = "test_op", Order = 0, IsEnabled = true }
}
};
/// <summary>
/// Generates a CNC program where some SavePositionNodes are followed by InspectionModuleNodes
/// and some are not. Returns the program along with metadata about which pairs exist.
/// </summary>
private static Gen<CncProgram> CncProgramWithMixedFollowersGen()
{
return from saveCount in Gen.Choose(1, 5)
from followFlags in Gen.ListOf<bool>(ArbMap.Default.GeneratorFor<bool>(), saveCount)
from programName in NonEmptyStringGen
let nodes = BuildMixedNodes(saveCount, followFlags)
select new CncProgram(
Guid.NewGuid(),
programName,
DateTime.UtcNow,
DateTime.UtcNow,
nodes.AsReadOnly());
}
private static List<CncNode> BuildMixedNodes(int saveCount, IReadOnlyList<bool> followFlags)
{
var nodes = new List<CncNode>();
int index = 0;
for (int i = 0; i < saveCount; i++)
{
// Add a SavePositionNode
var spNode = new SavePositionNode(
Guid.NewGuid(),
index,
$"Position_{i}",
MotionState.Default,
SaveImage: false,
ManualImagePath: "");
nodes.Add(spNode);
index++;
// Optionally follow with an InspectionModuleNode
if (i < followFlags.Count && followFlags[i])
{
var pipeline = new PipelineModel
{
Name = $"Pipeline_{i}",
Nodes = new List<PipelineNodeModel>
{
new PipelineNodeModel { OperatorKey = "test_op", Order = 0, IsEnabled = true }
}
};
var inspNode = new InspectionModuleNode(
Guid.NewGuid(),
index,
$"Inspect_{i}",
pipeline);
nodes.Add(inspNode);
index++;
}
}
return nodes;
}
// ── Service Factory ──────────────────────────────────────────────────────
private static (
CncExecutionService Service,
Mock<IPipelineExecutionService> PipelineExec,
Mock<IMainViewportService> MainViewport,
Mock<IImagePersistenceService> ImagePersistence,
Mock<ILoggerService> Logger)
CreateServiceWithCapture(BitmapSource detectorImage)
{
var mockStore = new Mock<IInspectionResultStore>();
var mockLogger = new Mock<ILoggerService>();
var mockMainViewportService = new Mock<IMainViewportService>();
var mockAppStateService = new Mock<IAppStateService>();
var mockPipelineExecutionService = new Mock<IPipelineExecutionService>();
var mockImageProcessingService = new Mock<IImageProcessingService>();
var mockEventAggregator = new Mock<IEventAggregator>();
var mockImagePersistenceService = new Mock<IImagePersistenceService>();
mockLogger.Setup(l => l.ForModule<CncExecutionService>()).Returns(mockLogger.Object);
mockEventAggregator
.Setup(ea => ea.GetEvent<DetectorDisconnectedEvent>())
.Returns(new DetectorDisconnectedEvent());
// Return the detector image from the viewport service (simulates detector acquisition)
mockMainViewportService
.Setup(m => m.CurrentDisplayImage)
.Returns(detectorImage);
mockStore.Setup(s => s.BeginRunAsync(
It.IsAny<InspectionRunRecord>(),
It.IsAny<InspectionAssetWriteRequest>()))
.Returns(Task.CompletedTask);
mockStore.Setup(s => s.AppendNodeResultAsync(
It.IsAny<InspectionNodeResult>(),
It.IsAny<IEnumerable<InspectionMetricResult>>(),
It.IsAny<PipelineExecutionSnapshot>(),
It.IsAny<IEnumerable<InspectionAssetWriteRequest>>()))
.Returns(Task.CompletedTask);
mockStore.Setup(s => s.CompleteRunAsync(
It.IsAny<Guid>(),
It.IsAny<bool?>(),
It.IsAny<DateTime?>()))
.Returns(Task.CompletedTask);
mockImagePersistenceService
.Setup(s => s.SaveImageAsync(
It.IsAny<byte[]>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ImageSaveResult(true, "test.bmp", 100));
mockImagePersistenceService
.Setup(s => s.WriteSummaryAsync(
It.IsAny<BatchCaptureResult>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
var service = new CncExecutionService(
mockStore.Object,
mockLogger.Object,
mockMainViewportService.Object,
mockAppStateService.Object,
mockPipelineExecutionService.Object,
mockImageProcessingService.Object,
mockEventAggregator.Object,
mockImagePersistenceService.Object);
return (service, mockPipelineExecutionService, mockMainViewportService, mockImagePersistenceService, mockLogger);
}
// ── Property 6: 采集图像传递给紧邻的 InspectionModuleNode ──────────────
// Feature: cnc-multi-position-image-capture, Property 6: 采集图像传递给紧邻的 InspectionModuleNode
// Validates: Requirements 3.2
[Property(MaxTest = 100)]
public Property AcquiredImage_PassedToFollowingInspectionModuleNode()
{
return Prop.ForAll(
CncProgramWithMixedFollowersGen().ToArbitrary(),
program =>
{
var detectorImage = CreateTestBitmap();
var (service, mockPipelineExec, _, _, _) = CreateServiceWithCapture(detectorImage);
// Track all pipeline execution calls with their source images
var pipelineCalls = new List<BitmapSource>();
mockPipelineExec
.Setup(p => p.ExecutePipelineAsync(
It.IsAny<IEnumerable<PipelineNodeViewModel>>(),
It.IsAny<BitmapSource>(),
It.IsAny<IProgress<PipelineProgress>>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<PipelineNodeViewModel>, BitmapSource, IProgress<PipelineProgress>, CancellationToken>(
(nodes, source, progress, ct) =>
{
pipelineCalls.Add(source);
})
.ReturnsAsync(new PipelineExecutionResult(detectorImage, null));
service.ExecuteAsync(program, null, CancellationToken.None)
.GetAwaiter().GetResult();
// Determine expected behavior from the program structure
var allNodesOrdered = program.Nodes.OrderBy(n => n.Index).ToList();
var savePositionNodes = allNodesOrdered.OfType<SavePositionNode>().ToList();
// Count SavePositionNodes that are immediately followed by InspectionModuleNode
int savePositionFollowedByInspection = 0;
foreach (var sp in savePositionNodes)
{
int spOrderIndex = allNodesOrdered.FindIndex(n => n.Id == sp.Id);
if (spOrderIndex >= 0 && spOrderIndex + 1 < allNodesOrdered.Count)
{
var nextNode = allNodesOrdered[spOrderIndex + 1];
if (nextNode is InspectionModuleNode)
savePositionFollowedByInspection++;
}
}
// Property verification:
// 1. When a SavePositionNode is followed by InspectionModuleNode, pipeline MUST be called
// at least once with the detector image (from the multi-position loop)
// 2. All pipeline calls must receive the detector image as source
// (since all images come from the same detector mock)
// If there are SavePositionNodes followed by InspectionModuleNodes,
// the pipeline must have been called at least that many times
bool pipelineCalledForFollowedNodes = pipelineCalls.Count >= savePositionFollowedByInspection;
// Every pipeline call must have received the detector image
bool allReceivedCorrectImage = pipelineCalls.All(img =>
ReferenceEquals(img, detectorImage));
return pipelineCalledForFollowedNodes && allReceivedCorrectImage;
});
}
// ── Property 6 (negative case): SavePositionNode NOT followed by InspectionModuleNode ──
// Feature: cnc-multi-position-image-capture, Property 6 (negative): No pipeline execution for non-followed SavePositionNodes
// Validates: Requirements 3.2
[Property(MaxTest = 100)]
public Property NoPipelineExecution_WhenNoFollowingInspectionModuleNode()
{
// Generate programs where NO SavePositionNode is followed by an InspectionModuleNode
var gen = from saveCount in Gen.Choose(1, 5)
from programName in NonEmptyStringGen
let nodes = BuildNodesWithoutFollowingInspection(saveCount)
select new CncProgram(
Guid.NewGuid(),
programName,
DateTime.UtcNow,
DateTime.UtcNow,
nodes.AsReadOnly());
return Prop.ForAll(
gen.ToArbitrary(),
program =>
{
var detectorImage = CreateTestBitmap();
var (service, mockPipelineExec, _, _, _) = CreateServiceWithCapture(detectorImage);
int pipelineCallCount = 0;
mockPipelineExec
.Setup(p => p.ExecutePipelineAsync(
It.IsAny<IEnumerable<PipelineNodeViewModel>>(),
It.IsAny<BitmapSource>(),
It.IsAny<IProgress<PipelineProgress>>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<PipelineNodeViewModel>, BitmapSource, IProgress<PipelineProgress>, CancellationToken>(
(_, _, _, _) => Interlocked.Increment(ref pipelineCallCount))
.ReturnsAsync(new PipelineExecutionResult(detectorImage, null));
service.ExecuteAsync(program, null, CancellationToken.None)
.GetAwaiter().GetResult();
// No pipeline execution should occur for SavePositionNodes
// (InspectionModuleNodes in the general loop may still trigger pipeline,
// but since we only have SavePositionNodes and non-inspection nodes, count should be 0)
return pipelineCallCount == 0;
});
}
private static List<CncNode> BuildNodesWithoutFollowingInspection(int saveCount)
{
var nodes = new List<CncNode>();
int index = 0;
for (int i = 0; i < saveCount; i++)
{
// Add a SavePositionNode
nodes.Add(new SavePositionNode(
Guid.NewGuid(),
index,
$"Position_{i}",
MotionState.Default,
SaveImage: false,
ManualImagePath: ""));
index++;
// Follow with a non-inspection node (e.g., WaitDelayNode with 0ms)
nodes.Add(new WaitDelayNode(
Guid.NewGuid(),
index,
$"Wait_{i}",
0));
index++;
}
return nodes;
}
}
}