#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 8: 取消令牌中止后续位置执行 /// **Validates: Requirements 3.5** /// /// For any CNC program being executed, when a CancellationToken is triggered, /// no SavePositionNode with Index greater than the currently executing node SHALL be processed. /// public class CancellationPropertyTests { /// /// Creates a CncExecutionService with all dependencies mocked. /// The SaveImageAsync mock triggers cancellation after K positions have been processed. /// private static (CncExecutionService Service, Mock ImagePersistence, List ExecutedNodeNames) CreateServiceWithCancellation(CancellationTokenSource cts, int cancelAfterK) { 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(); // Logger setup mockLogger.Setup(l => l.ForModule()).Returns(mockLogger.Object); // EventAggregator setup - prevent NRE on constructor subscription mockEventAggregator .Setup(ea => ea.GetEvent()) .Returns(new DetectorDisconnectedEvent()); // InspectionResultStore setup 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); // Provide a valid BitmapSource from the viewport so image acquisition succeeds var dummyBitmap = BitmapSource.Create( 1, 1, 96, 96, PixelFormats.Gray8, null, new byte[] { 128 }, 1); dummyBitmap.Freeze(); mockMainViewportService .Setup(m => m.LatestManualImage) .Returns(dummyBitmap); // Track which nodes have their images saved (i.e., which positions were executed) var executedNodeNames = new List(); int saveCallCount = 0; // ImagePersistenceService - cancel after K positions have been saved mockImagePersistenceService .Setup(s => s.SaveImageAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns((_, nodeName, __, ___) => { executedNodeNames.Add(nodeName); int currentCount = Interlocked.Increment(ref saveCallCount); // Cancel after K positions have been processed if (currentCount >= cancelAfterK) { cts.Cancel(); } return Task.FromResult(new ImageSaveResult(true, $"C:\\test\\{nodeName}.bmp", 1024)); }); 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, mockImagePersistenceService, executedNodeNames); } /// /// FsCheck generator for a cancellation test scenario: /// - N SavePositionNodes (N between 2 and 10) /// - A cancellation point K (1 <= K < N), meaning cancel after K positions are processed /// private static Arbitrary<(List Nodes, int CancelAfterK)> CancellationScenarioArb() { var gen = from count in Gen.Choose(2, 10) from cancelAfterK in Gen.Choose(1, count - 1) from indices in Gen.Shuffle(Enumerable.Range(0, count).ToArray()) .Select(arr => arr.ToList()) select ( Nodes: indices.Select(idx => new SavePositionNode( Guid.NewGuid(), idx, $"Position_{idx}", MotionState.Default, SaveImage: true, ManualImagePath: "")) .ToList(), CancelAfterK: cancelAfterK ); return gen.ToArbitrary(); } // Feature: cnc-multi-position-image-capture, Property 8: 取消令牌中止后续位置执行 // **Validates: Requirements 3.5** [Property(MaxTest = 100)] public Property Cancellation_StopsSubsequentPositionExecution() { return Prop.ForAll( CancellationScenarioArb(), scenario => { var (nodes, cancelAfterK) = scenario; using var cts = new CancellationTokenSource(); var (service, _, executedNodeNames) = CreateServiceWithCancellation(cts, cancelAfterK); // Build a CncProgram containing the SavePositionNodes // (in their original random order - the service sorts by Index) var program = new CncProgram( Guid.NewGuid(), "TestProgram", DateTime.UtcNow, DateTime.UtcNow, nodes.Cast().ToList().AsReadOnly()); // Track progress reports to verify which positions were executed var executedPositionIndices = new List(); var progress = new SynchronousProgress(p => { if (p.State == NodeExecutionState.Running && p.PositionIndex.HasValue) { executedPositionIndices.Add(p.PositionIndex.Value); } }); service.ExecuteAsync(program, progress, cts.Token) .GetAwaiter().GetResult(); // The expected sorted order of nodes by Index var sortedNodes = nodes.OrderBy(n => n.Index).ToList(); // Verify: No more than cancelAfterK positions should have been executed. // The cancellation is triggered after K SaveImageAsync calls complete, // so at most K positions should have their images saved. // The (K+1)th position might start (Running reported) but its SaveImageAsync // should not be called because cancellation is checked at loop start. bool noExcessiveSaves = executedNodeNames.Count <= cancelAfterK; // Verify: Positions after the cancellation point should NOT be executed. // The service checks cancellation at the start of each loop iteration, // so after K positions complete and cancellation is triggered, // subsequent positions should not receive Running state. bool noSubsequentExecution = executedPositionIndices.Count <= cancelAfterK; // Verify: The executed positions should be the first K positions in sorted order bool correctPositionsExecuted = true; for (int i = 0; i < executedNodeNames.Count && i < cancelAfterK; i++) { if (executedNodeNames[i] != sortedNodes[i].Name) { correctPositionsExecuted = false; break; } } return noExcessiveSaves && noSubsequentExecution && correctPositionsExecuted; }); } } }