#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 { /// /// FsCheck property-based tests for cancellation summary correctness. /// Property 11: 取消时摘要包含正确的完成/未执行计数 /// **Validates: Requirements 4.5** /// public class CancellationSummaryPropertyTests { // ── Helper: Create a frozen BitmapSource for mocking ────────────── private static BitmapSource CreateFrozenBitmap() { var bitmap = BitmapSource.Create(1, 1, 96, 96, PixelFormats.Bgra32, null, new byte[4], 4); bitmap.Freeze(); return bitmap; } // ── Helper: Create CncExecutionService with mocks ──────────────── private static (CncExecutionService Service, Mock ImagePersistence) CreateServiceWithImagePersistence(BitmapSource sourceImage) { 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); // Provide a source image so positions can succeed mockMainViewportService.SetupGet(m => m.LatestManualImage).Returns(sourceImage); mockMainViewportService.SetupGet(m => m.CurrentDisplayImage).Returns(sourceImage); // SaveImageAsync returns success mockImagePersistenceService .Setup(s => s.SaveImageAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new ImageSaveResult(true, "C:\\test\\image.bmp", 1024)); // WriteSummaryAsync returns success 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); } // ── Generator: N SavePositionNodes with cancellation point K ───── private static Gen<(List Nodes, int CancelAfterK)> SavePositionNodesWithCancelGen => from n in Gen.Choose(2, 10) from k in Gen.Choose(0, n - 1) from nodes in GenSavePositionNodes(n) select (nodes, k); 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 let node = new SavePositionNode( Guid.NewGuid(), idx, $"Position_{idx}", MotionState.Default, SaveImage: true) select new List(list) { node }; } return acc; } // ── Property 11: 取消时摘要包含正确的完成/未执行计数 ───────────── /// /// **Validates: Requirements 4.5** /// /// For any CNC program execution that is cancelled at position K (0-based) /// out of N total positions, the summary SHALL have Status="Cancelled", /// CompletedBeforeCancel=K, and NotExecutedAfterCancel=N-K, /// where CompletedBeforeCancel + NotExecutedAfterCancel == TotalPositions. /// [Property(MaxTest = 100)] public Property CancellationSummary_HasCorrectCompletedAndNotExecutedCounts() { return Prop.ForAll( SavePositionNodesWithCancelGen.ToArbitrary(), tuple => { var (nodes, cancelAfterK) = tuple; int totalPositions = nodes.Count; var sourceImage = CreateFrozenBitmap(); var (service, mockImagePersistence) = CreateServiceWithImagePersistence(sourceImage); // Create a CTS that we will cancel after K positions are processed using var cts = new CancellationTokenSource(); int processedCount = 0; // Use progress callback to trigger cancellation after K positions complete var progress = new SynchronousProgress(p => { // Count positions that have completed (Succeeded or Failed state) if (p.State == NodeExecutionState.Succeeded || p.State == NodeExecutionState.Failed) { if (p.PositionIndex.HasValue) { processedCount++; if (processedCount >= cancelAfterK) { cts.Cancel(); } } } }); var program = new CncProgram( Guid.NewGuid(), "TestProgram", DateTime.UtcNow, DateTime.UtcNow, nodes.Cast().ToList().AsReadOnly()); service.ExecuteAsync(program, progress, cts.Token) .GetAwaiter().GetResult(); // Capture the BatchCaptureResult passed to WriteSummaryAsync BatchCaptureResult capturedResult = null; mockImagePersistence.Verify( s => s.WriteSummaryAsync( It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); // Extract the captured argument var invocation = mockImagePersistence.Invocations .First(i => i.Method.Name == nameof(IImagePersistenceService.WriteSummaryAsync)); capturedResult = (BatchCaptureResult)invocation.Arguments[0]; // Verify the cancellation summary properties bool statusIsCancelled = capturedResult.Status == "Cancelled"; bool completedBeforeCancelIsK = capturedResult.CompletedBeforeCancel == cancelAfterK; bool notExecutedIsCorrect = capturedResult.NotExecutedAfterCancel == totalPositions - cancelAfterK; bool sumEqualsTotal = capturedResult.CompletedBeforeCancel + capturedResult.NotExecutedAfterCancel == totalPositions; bool totalPositionsCorrect = capturedResult.TotalPositions == totalPositions; return statusIsCancelled && completedBeforeCancelIsK && notExecutedIsCorrect && sumEqualsTotal && totalPositionsCorrect; }); } } }