#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; }); } } }