264 lines
12 KiB
C#
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;
|
|
});
|
|
}
|
|
}
|
|
}
|