Files
XplorePlane/XplorePlane.Tests/Services/ExecutionResiliencePropertyTests.cs
T
2026-05-15 15:29:53 +08:00

233 lines
10 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<IMainViewportService> MainViewport,
Mock<IImagePersistenceService> ImagePersistence)
CreateServiceWithMocks()
{
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, mockMainViewportService, mockImagePersistenceService);
}
/// <summary>
/// FsCheck Arbitrary for resilience test scenarios.
/// Generates (SavePositionNodes, FailureModes) where:
/// - Nodes: 2-6 SavePositionNodes with SaveImage=true
/// - FailureModes: per-position int (0=success, 1=detector null, 2=save I/O error)
/// - At least one position has a failure
/// </summary>
private static Arbitrary<(List<SavePositionNode> Nodes, List<int> FailureModes)> ResilienceScenarioArb()
{
// Generate 6 failure mode values upfront, then take posCount of them
var gen =
from posCount in Gen.Choose(2, 6)
from m0 in Gen.Choose(0, 2)
from m1 in Gen.Choose(0, 2)
from m2 in Gen.Choose(0, 2)
from m3 in Gen.Choose(0, 2)
from m4 in Gen.Choose(0, 2)
from m5 in Gen.Choose(0, 2)
let allModes = new List<int> { m0, m1, m2, m3, m4, m5 }
let modes = allModes.Take(posCount).ToList()
// Ensure at least one failure exists
let adjustedModes = modes.Any(m => m > 0)
? modes
: modes.Select((m, i) => i == 0 ? 1 : m).ToList()
let nodes = Enumerable.Range(0, posCount)
.Select(i => new SavePositionNode(
Guid.NewGuid(), i, $"Position_{i}",
MotionState.Default, SaveImage: true))
.ToList()
select (Nodes: nodes, FailureModes: adjustedModes);
return gen.ToArbitrary();
}
// Feature: cnc-multi-position-image-capture, Property 3: 采集或保存失败不中断后续执行
// **Validates: Requirements 1.5, 1.6, 3.3, 3.6**
[Property(MaxTest = 100)]
public Property AcquisitionOrSaveFailure_DoesNotInterruptSubsequentPositions()
{
return Prop.ForAll(
ResilienceScenarioArb(),
scenario =>
{
var (saveNodes, failureModes) = scenario;
int totalPositions = saveNodes.Count;
// Determine which positions have detector failures vs save failures
var detectorFailIndices = new HashSet<int>();
var saveFailIndices = new HashSet<int>();
for (int i = 0; i < totalPositions; i++)
{
switch (failureModes[i])
{
case 1: detectorFailIndices.Add(i); break;
case 2: saveFailIndices.Add(i); break;
}
}
var (service, mockMainViewport, mockImagePersistence) = CreateServiceWithMocks();
var testBitmap = CreateTestBitmap();
// ── Configure detector mock ──
// TryGetSourceImage reads LatestManualImage first, then CurrentDisplayImage.
// Use LatestManualImage to control image return per call.
int imageCallCount = 0;
mockMainViewport.SetupGet(m => m.LatestManualImage)
.Returns(() =>
{
int callNum = Interlocked.Increment(ref imageCallCount);
// Call 1 = initial runSourceImage at start of ExecuteAsync - always valid
if (callNum == 1)
return testBitmap;
// Subsequent calls: callNum-2 gives the 0-based position index
int posIdx = callNum - 2;
if (posIdx >= 0 && detectorFailIndices.Contains(posIdx))
return null; // Simulate detector failure
return testBitmap;
});
// ── Configure image persistence mock ──
mockImagePersistence.Setup(s => s.SaveImageAsync(
It.IsAny<byte[]>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.Returns<byte[], string, string, CancellationToken>((_, nodeName, __, ___) =>
{
var posIdx = saveNodes.FindIndex(n => n.Name == nodeName);
if (posIdx >= 0 && saveFailIndices.Contains(posIdx))
{
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 HashSet<Guid>();
var progress = new SynchronousProgress<CncNodeExecutionProgress>(p =>
{
// Track all nodes that received a Running report with PositionIndex
if (p.State == NodeExecutionState.Running && p.PositionIndex.HasValue)
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,
// regardless of whether earlier positions failed.
bool allPositionsAttempted = reportedNodeIds.Count == totalPositions;
return allPositionsAttempted;
});
}
}
}