Files
XplorePlane/XplorePlane.Tests/Services/ExecutionResiliencePropertyTests.cs
T

264 lines
12 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 Xunit;
namespace XplorePlane.Tests.Services
{
/// <summary>
/// Property 3: 采集或保存失败不中断后续执行
/// **Validates: Requirements 1.5, 1.6, 3.3, 3.6**
///
/// For any CNC program with multiple SavePositionNodes, if image acquisition fails
/// (detector returns null) or image persistence fails (I/O error) at any position,
/// all subsequent positions SHALL still be attempted for execution.
/// </summary>
public class ExecutionResiliencePropertyTests
{
// ── Helper: Create a frozen BitmapSource for testing ──
private static BitmapSource CreateTestBitmap()
{
var stride = 4;
var pixels = new byte[stride * 1];
var bitmap = BitmapSource.Create(1, 1, 96, 96, PixelFormats.Bgra32, null, pixels, stride);
bitmap.Freeze();
return bitmap;
}
// ── Helper: Create service with full mock access ──
private static (
CncExecutionService Service,
Mock<IInspectionResultStore> Store,
Mock<ILoggerService> Logger,
Mock<IMainViewportService> MainViewport,
Mock<IAppStateService> AppState,
Mock<IImagePersistenceService> ImagePersistence)
CreateServiceWithImagePersistence()
{
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());
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);
// Default: WriteSummaryAsync succeeds
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, mockStore, mockLogger, mockMainViewportService, mockAppStateService, mockImagePersistenceService);
}
// ── Generator: Produce N SavePositionNodes with unique ascending indices ──
private static Gen<List<SavePositionNode>> SavePositionNodesGen(int minCount, int maxCount)
{
return
from count in Gen.Choose(minCount, maxCount)
from nodes in GenSavePositionNodes(count)
select nodes;
}
private static Gen<List<SavePositionNode>> GenSavePositionNodes(int count)
{
Gen<List<SavePositionNode>> acc = Gen.Constant(new List<SavePositionNode>());
for (int i = 0; i < count; i++)
{
var idx = i;
acc = from list in acc
from saveImage in Gen.Elements(true, false)
select new List<SavePositionNode>(list)
{
new SavePositionNode(
Guid.NewGuid(), idx, $"Pos_{idx}",
MotionState.Default, SaveImage: saveImage)
};
}
return acc;
}
/// <summary>
/// Generator for a set of failure indices (positions where acquisition or save will fail).
/// Ensures at least one failure position and that failures don't cover ALL positions
/// (so we can verify subsequent positions still execute).
/// </summary>
private static Gen<(HashSet<int> DetectorFailIndices, HashSet<int> SaveFailIndices)> FailureIndicesGen(int totalPositions)
{
return
from failCount in Gen.Choose(1, Math.Max(1, totalPositions - 1))
from failIndices in Gen.Shuffle(Enumerable.Range(0, totalPositions).ToArray())
.Select(arr => arr.Take(failCount).ToList())
from splitPoint in Gen.Choose(0, failCount)
let detectorFails = new HashSet<int>(failIndices.Take(splitPoint))
let saveFails = new HashSet<int>(failIndices.Skip(splitPoint))
select (detectorFails, saveFails);
}
// ── Property Test ──
/// <summary>
/// Feature: cnc-multi-position-image-capture, Property 3: 采集或保存失败不中断后续执行
/// **Validates: Requirements 1.5, 1.6, 3.3, 3.6**
///
/// For any CNC program with N >= 2 SavePositionNodes, if image acquisition fails
/// (detector returns null) or image persistence fails (I/O error) at randomly selected
/// positions, ALL N positions SHALL still be attempted (progress reported for each).
/// </summary>
[Property(MaxTest = 100)]
public Property AcquisitionOrSaveFailure_DoesNotInterruptSubsequentPositions()
{
var gen =
from nodes in SavePositionNodesGen(2, 8)
from failures in FailureIndicesGen(nodes.Count)
select (nodes, failures.DetectorFailIndices, failures.SaveFailIndices);
return Prop.ForAll(
gen.ToArbitrary(),
tuple =>
{
var (saveNodes, detectorFailIndices, saveFailIndices) = tuple;
int totalPositions = saveNodes.Count;
var (service, _, _, mockMainViewport, _, mockImagePersistence) =
CreateServiceWithImagePersistence();
// ── Configure detector mock ──
// For positions where detector should fail, return null.
// For others, return a valid image.
var testBitmap = CreateTestBitmap();
// TryGetSourceImage reads from LatestManualImage first, then CurrentDisplayImage.
// We use CurrentDisplayImage to control per-position behavior.
mockMainViewport.SetupGet(m => m.LatestManualImage)
.Returns((System.Windows.Media.ImageSource)null);
// Use a callback-based setup to return null or valid image based on position
var imageSequence = new Queue<BitmapSource>();
foreach (var node in saveNodes)
{
int nodeIndex = saveNodes.IndexOf(node);
if (detectorFailIndices.Contains(nodeIndex))
imageSequence.Enqueue(null); // Detector fails
else
imageSequence.Enqueue(testBitmap); // Detector succeeds
}
// Also need an initial call for runSourceImage at the start of ExecuteAsync
mockMainViewport.Setup(m => m.CurrentDisplayImage)
.Returns(() =>
{
if (imageSequence.Count > 0)
return imageSequence.Dequeue();
return testBitmap;
});
// ── Configure image persistence mock ──
// For positions where save should fail, throw IOException.
// For others, return success.
mockImagePersistence.Setup(s => s.SaveImageAsync(
It.IsAny<byte[]>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.Returns<byte[], string, string, CancellationToken>((_, nodeName, __, ___) =>
{
// Find the position index by node name
var posIdx = saveNodes.FindIndex(n => n.Name == nodeName);
if (posIdx >= 0 && saveFailIndices.Contains(posIdx))
{
// Simulate I/O failure
return Task.FromResult(new ImageSaveResult(
false, string.Empty, 0, "Simulated I/O error"));
}
return Task.FromResult(new ImageSaveResult(
true, $"C:\\Images\\{nodeName}.bmp", 1024, null));
});
// ── Build CNC program ──
var program = new CncProgram(
Guid.NewGuid(), "TestProgram",
DateTime.UtcNow, DateTime.UtcNow,
saveNodes.Cast<CncNode>().ToList().AsReadOnly());
// ── Track progress reports ──
var reportedNodeIds = new List<Guid>();
var progress = new SynchronousProgress<CncNodeExecutionProgress>(p =>
{
// Count Running reports for SavePositionNodes (first report per position)
if (p.State == NodeExecutionState.Running && p.PositionIndex.HasValue)
{
if (!reportedNodeIds.Contains(p.NodeId))
reportedNodeIds.Add(p.NodeId);
}
});
// ── Execute ──
service.ExecuteAsync(program, progress, CancellationToken.None)
.GetAwaiter().GetResult();
// ── Verify: ALL positions were attempted ──
// Each SavePositionNode should have received at least one Running progress report
bool allPositionsAttempted = reportedNodeIds.Count == totalPositions;
return allPositionsAttempted;
});
}
}
}