#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
{
///
/// 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.
///
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 NonEmptyStringGen =>
ArbMap.Default.GeneratorFor().Select(s => s.Get);
private static Gen PipelineGen =>
from name in NonEmptyStringGen
select new PipelineModel
{
Name = name,
Nodes = new List
{
new PipelineNodeModel { OperatorKey = "test_op", Order = 0, IsEnabled = true }
}
};
///
/// 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.
///
private static Gen CncProgramWithMixedFollowersGen()
{
return from saveCount in Gen.Choose(1, 5)
from followFlags in Gen.ListOf(ArbMap.Default.GeneratorFor(), 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 BuildMixedNodes(int saveCount, IReadOnlyList followFlags)
{
var nodes = new List();
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
{
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 PipelineExec,
Mock MainViewport,
Mock ImagePersistence,
Mock Logger)
CreateServiceWithCapture(BitmapSource detectorImage)
{
var mockStore = new Mock();
var mockLogger = new Mock();
var mockMainViewportService = new Mock();
var mockAppStateService = new Mock();
var mockPipelineExecutionService = new Mock();
var mockImageProcessingService = new Mock();
var mockEventAggregator = new Mock();
var mockImagePersistenceService = new Mock();
mockLogger.Setup(l => l.ForModule()).Returns(mockLogger.Object);
mockEventAggregator
.Setup(ea => ea.GetEvent())
.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(),
It.IsAny()))
.Returns(Task.CompletedTask);
mockStore.Setup(s => s.AppendNodeResultAsync(
It.IsAny(),
It.IsAny>(),
It.IsAny(),
It.IsAny>()))
.Returns(Task.CompletedTask);
mockStore.Setup(s => s.CompleteRunAsync(
It.IsAny(),
It.IsAny(),
It.IsAny()))
.Returns(Task.CompletedTask);
mockImagePersistenceService
.Setup(s => s.SaveImageAsync(
It.IsAny(),
It.IsAny(),
It.IsAny(),
It.IsAny()))
.ReturnsAsync(new ImageSaveResult(true, "test.bmp", 100));
mockImagePersistenceService
.Setup(s => s.WriteSummaryAsync(
It.IsAny(),
It.IsAny(),
It.IsAny()))
.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();
mockPipelineExec
.Setup(p => p.ExecutePipelineAsync(
It.IsAny>(),
It.IsAny(),
It.IsAny>(),
It.IsAny()))
.Callback, BitmapSource, IProgress, 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().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>(),
It.IsAny(),
It.IsAny>(),
It.IsAny()))
.Callback, BitmapSource, IProgress, 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 BuildNodesWithoutFollowingInspection(int saveCount)
{
var nodes = new List();
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;
}
}
}