#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
{
///
/// 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.
///
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 Store,
Mock Logger,
Mock MainViewport,
Mock AppState,
Mock ImagePersistence)
CreateServiceWithImagePersistence()
{
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());
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);
// Default: WriteSummaryAsync succeeds
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, mockStore, mockLogger, mockMainViewportService, mockAppStateService, mockImagePersistenceService);
}
// ── Generator: Produce N SavePositionNodes with unique ascending indices ──
private static Gen> SavePositionNodesGen(int minCount, int maxCount)
{
return
from count in Gen.Choose(minCount, maxCount)
from nodes in GenSavePositionNodes(count)
select nodes;
}
private static Gen> GenSavePositionNodes(int count)
{
Gen> acc = Gen.Constant(new List());
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(list)
{
new SavePositionNode(
Guid.NewGuid(), idx, $"Pos_{idx}",
MotionState.Default, SaveImage: saveImage)
};
}
return acc;
}
///
/// 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).
///
private static Gen<(HashSet DetectorFailIndices, HashSet 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(failIndices.Take(splitPoint))
let saveFails = new HashSet(failIndices.Skip(splitPoint))
select (detectorFails, saveFails);
}
// ── Property Test ──
///
/// 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).
///
[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();
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(),
It.IsAny(),
It.IsAny(),
It.IsAny()))
.Returns((_, 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().ToList().AsReadOnly());
// ── Track progress reports ──
var reportedNodeIds = new List();
var progress = new SynchronousProgress(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;
});
}
}
}