#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 { /// /// Feature: cnc-multi-position-image-capture, Property 2: 图像持久化对所有 SaveImage=true 节点触发 /// **Validates: Requirements 1.1, 2.5** /// /// For any CNC program containing one or more SavePositionNode with SaveImage=true /// (regardless of whether ManualImagePath is set or empty), the CNC_Execution_Service /// SHALL invoke the Image_Persistence_Service exactly once per such node that is reached /// during execution. /// public class ImagePersistenceCallPropertyTests { /// /// Creates a 1x1 pixel frozen BitmapSource for testing purposes. /// private static BitmapSource CreateTestBitmap() { var bitmap = BitmapSource.Create( 1, 1, 96, 96, PixelFormats.Gray16, null, new byte[] { 0xFF, 0xFF }, 2); bitmap.Freeze(); return bitmap; } /// /// Creates a fully mocked CncExecutionService with IMainViewportService returning /// a valid image (so TryGetSourceImage always succeeds) and IImagePersistenceService /// tracking SaveImageAsync calls. /// private static (CncExecutionService Service, Mock ImagePersistence) CreateServiceWithImageSupport() { 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); // Set up GetEvent() so the constructor subscription doesn't throw mockEventAggregator .Setup(ea => ea.GetEvent()) .Returns(new DetectorDisconnectedEvent()); // Mock IMainViewportService to return a valid image so TryGetSourceImage() succeeds var testBitmap = CreateTestBitmap(); mockMainViewportService .Setup(m => m.LatestManualImage) .Returns(testBitmap); // Mock IInspectionResultStore methods 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); // Mock SaveImageAsync to return success mockImagePersistenceService .Setup(s => s.SaveImageAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new ImageSaveResult(true, @"C:\test\image.bmp", 1024)); // Mock WriteSummaryAsync to return 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 for a list of SavePositionNodes with random SaveImage flags. /// Ensures at least one node has SaveImage=true. /// private static Gen> SavePositionNodesGen => from count in Gen.Choose(1, 8) from flags in Gen.ListOf(ArbMap.Default.GeneratorFor(), count) let hasAtLeastOneTrue = flags.Any(f => f) let adjustedFlags = hasAtLeastOneTrue ? flags : flags.Select((f, i) => i == 0 || f).ToList() from names in Gen.ListOf(ArbMap.Default.GeneratorFor().Select(s => s.Get), count) select Enumerable.Range(0, count) .Select(i => new SavePositionNode( Guid.NewGuid(), i, names[i], new MotionState(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0), SaveImage: adjustedFlags[i], ManualImagePath: "")) .ToList(); /// /// Generator for SavePositionNodes where some have ManualImagePath set (non-empty) /// and some don't, but all with SaveImage=true to test that persistence is called /// regardless of ManualImagePath being set. /// Note: ManualImagePath validation requires file to exist, so we leave it empty /// in this test (the detector mock provides the image). /// private static Gen> SavePositionNodesAllSaveImageTrueGen => from count in Gen.Choose(1, 8) from names in Gen.ListOf(ArbMap.Default.GeneratorFor().Select(s => s.Get), count) select Enumerable.Range(0, count) .Select(i => new SavePositionNode( Guid.NewGuid(), i, names[i], new MotionState(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0), SaveImage: true, ManualImagePath: "")) .ToList(); // ── Property 2: 图像持久化对所有 SaveImage=true 节点触发 ────────────── /// /// Feature: cnc-multi-position-image-capture, Property 2: 图像持久化对所有 SaveImage=true 节点触发 /// **Validates: Requirements 1.1, 2.5** /// /// For any CNC program containing SavePositionNodes with mixed SaveImage flags, /// SaveImageAsync is called exactly N times where N = count of nodes with SaveImage=true. /// [Property(MaxTest = 100)] public Property SaveImageAsync_CalledExactlyOncePerSaveImageTrueNode() { return Prop.ForAll( SavePositionNodesGen.ToArbitrary(), nodes => { var (service, mockImagePersistence) = CreateServiceWithImageSupport(); var program = new CncProgram( Guid.NewGuid(), "TestProgram", DateTime.UtcNow, DateTime.UtcNow, nodes.Select(n => (CncNode)n).ToList().AsReadOnly()); service.ExecuteAsync(program, null, CancellationToken.None) .GetAwaiter().GetResult(); int expectedCalls = nodes.Count(n => n.SaveImage); mockImagePersistence.Verify( s => s.SaveImageAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(expectedCalls)); return true; }); } /// /// Feature: cnc-multi-position-image-capture, Property 2 (all true variant): /// When all nodes have SaveImage=true, SaveImageAsync is called exactly N times (one per node). /// **Validates: Requirements 1.1, 2.5** /// [Property(MaxTest = 100)] public Property SaveImageAsync_CalledForEveryNode_WhenAllSaveImageTrue() { return Prop.ForAll( SavePositionNodesAllSaveImageTrueGen.ToArbitrary(), nodes => { var (service, mockImagePersistence) = CreateServiceWithImageSupport(); var program = new CncProgram( Guid.NewGuid(), "TestProgram", DateTime.UtcNow, DateTime.UtcNow, nodes.Select(n => (CncNode)n).ToList().AsReadOnly()); service.ExecuteAsync(program, null, CancellationToken.None) .GetAwaiter().GetResult(); // All nodes have SaveImage=true, so SaveImageAsync should be called for each mockImagePersistence.Verify( s => s.SaveImageAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(nodes.Count)); return true; }); } /// /// Feature: cnc-multi-position-image-capture, Property 2 (none true variant): /// When no nodes have SaveImage=true, SaveImageAsync is never called. /// **Validates: Requirements 1.1, 2.5** /// [Property(MaxTest = 100)] public Property SaveImageAsync_NeverCalled_WhenNoSaveImageTrue() { var nodesGen = from count in Gen.Choose(1, 8) from names in Gen.ListOf(ArbMap.Default.GeneratorFor().Select(s => s.Get), count) select Enumerable.Range(0, count) .Select(i => new SavePositionNode( Guid.NewGuid(), i, names[i], new MotionState(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0), SaveImage: false, ManualImagePath: "")) .ToList(); return Prop.ForAll( nodesGen.ToArbitrary(), nodes => { var (service, mockImagePersistence) = CreateServiceWithImageSupport(); var program = new CncProgram( Guid.NewGuid(), "TestProgram", DateTime.UtcNow, DateTime.UtcNow, nodes.Select(n => (CncNode)n).ToList().AsReadOnly()); service.ExecuteAsync(program, null, CancellationToken.None) .GetAwaiter().GetResult(); // No nodes have SaveImage=true, so SaveImageAsync should never be called mockImagePersistence.Verify( s => s.SaveImageAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); return true; }); } } }