diff --git a/XplorePlane.Tests/Helpers/FileNameSanitizerPropertyTests.cs b/XplorePlane.Tests/Helpers/FileNameSanitizerPropertyTests.cs new file mode 100644 index 0000000..21c2907 --- /dev/null +++ b/XplorePlane.Tests/Helpers/FileNameSanitizerPropertyTests.cs @@ -0,0 +1,143 @@ +using FsCheck; +using FsCheck.Fluent; +using FsCheck.Xunit; +using System.Linq; +using XplorePlane.Helpers; + +namespace XplorePlane.Tests.Helpers +{ + /// + /// FsCheck property-based tests for FileNameSanitizer. + /// Property 1: 文件名非法字符清理 + /// Validates: Requirements 1.2 + /// + public class FileNameSanitizerPropertyTests + { + private static readonly char[] InvalidChars = + new[] { '\\', '/', ':', '*', '?', '"', '<', '>', '|' }; + + /// + /// 生成包含各种字符(含非法字符)的文件名字符串 + /// + private static Arbitrary FileNameArb() + { + // Mix of normal chars, unicode chars, and illegal file system chars + var charGen = Gen.Frequency( + (5, Gen.Choose(0x20, 0x7E).Select(i => (char)i)), // printable ASCII + (2, Gen.Elements(InvalidChars)), // illegal chars + (2, Gen.Choose(0x4E00, 0x9FFF).Select(i => (char)i)), // CJK characters + (1, Gen.Elements('_', '-', '.', ' ')) // common filename chars + ); + + var gen = from len in Gen.Choose(1, 50) + from chars in Gen.ListOf(charGen, len) + select new string(chars.ToArray()); + + return gen.ToArbitrary(); + } + + // ── Property 1: Output never contains any illegal characters ───────── + + /// + /// **Validates: Requirements 1.2** + /// For any input string, the sanitized output must not contain any of the 9 illegal characters. + /// + [Property(MaxTest = 100)] + public Property SanitizedOutput_NeverContainsIllegalCharacters() + { + return Prop.ForAll( + FileNameArb(), + input => + { + var result = FileNameSanitizer.Sanitize(input); + return !result.Any(c => InvalidChars.Contains(c)); + }); + } + + // ── Property 1 (cont): Legal characters are preserved unchanged ────── + + /// + /// **Validates: Requirements 1.2** + /// For any input string, characters that are NOT illegal must appear unchanged + /// at the same position in the output. + /// + [Property(MaxTest = 100)] + public Property SanitizedOutput_PreservesLegalCharacters() + { + return Prop.ForAll( + FileNameArb(), + input => + { + var result = FileNameSanitizer.Sanitize(input); + + for (int i = 0; i < input.Length; i++) + { + if (!InvalidChars.Contains(input[i])) + { + if (result[i] != input[i]) + return false; + } + } + return true; + }); + } + + // ── Property 1 (cont): Output length equals input length ───────────── + + /// + /// **Validates: Requirements 1.2** + /// For any non-null, non-empty input, the output length equals the input length. + /// For null or empty input, the output is "_". + /// + [Property(MaxTest = 100)] + public Property SanitizedOutput_LengthEqualsInputLength() + { + return Prop.ForAll( + FileNameArb(), + input => + { + var result = FileNameSanitizer.Sanitize(input); + return result.Length == input.Length; + }); + } + + // ── Property 1 (cont): Sanitize is idempotent ──────────────────────── + + /// + /// **Validates: Requirements 1.2** + /// Sanitizing the output a second time produces the same result (idempotent). + /// + [Property(MaxTest = 100)] + public Property SanitizedOutput_IsIdempotent() + { + return Prop.ForAll( + FileNameArb(), + input => + { + var once = FileNameSanitizer.Sanitize(input); + var twice = FileNameSanitizer.Sanitize(once); + return once == twice; + }); + } + + // ── Edge case: null and empty input ────────────────────────────────── + + /// + /// **Validates: Requirements 1.2** + /// Null or empty input always returns "_". + /// + [Property(MaxTest = 100)] + public Property NullOrEmptyInput_ReturnsUnderscore() + { + var gen = Gen.Elements(null, "", null, ""); + + return Prop.ForAll( + gen.ToArbitrary(), + input => + { + var result = FileNameSanitizer.Sanitize(input); + return result == "_"; + }); + } + } +} diff --git a/XplorePlane.Tests/Helpers/ManualImageValidatorPropertyTests.cs b/XplorePlane.Tests/Helpers/ManualImageValidatorPropertyTests.cs new file mode 100644 index 0000000..2be51ae --- /dev/null +++ b/XplorePlane.Tests/Helpers/ManualImageValidatorPropertyTests.cs @@ -0,0 +1,213 @@ +using System; +using System.IO; +using FsCheck; +using FsCheck.Fluent; +using FsCheck.Xunit; +using XplorePlane.Helpers; + +namespace XplorePlane.Tests.Helpers +{ + /// + /// FsCheck property-based tests for ManualImageValidator. + /// Property 4: ManualImagePath 验证正确性 + /// **Validates: Requirements 2.1, 2.3** + /// + public class ManualImageValidatorPropertyTests + { + // ── Generators ────────────────────────────────────────────────────── + + /// + /// Generates null, empty, or whitespace-only strings. + /// + private static Gen NullOrWhitespaceGen() + { + return Gen.OneOf( + Gen.Constant((string)null), + Gen.Constant(string.Empty), + Gen.Constant(" "), + Gen.Constant(" "), + Gen.Constant("\t"), + Gen.Constant("\t \n"), + Gen.Constant(" \r\n ")); + } + + /// + /// Generates paths longer than 260 characters (non-whitespace). + /// + private static Gen PathTooLongGen() + { + // Generate lengths from 261 to 500 + return Gen.Choose(261, 500).Select(len => + { + // Use a valid-looking path prefix to ensure it's not whitespace + var prefix = @"C:\Images\"; + var remaining = len - prefix.Length; + if (remaining <= 0) remaining = 1; + return prefix + new string('a', remaining); + }); + } + + /// + /// Generates non-empty paths with length ≤ 260 that point to non-existent files. + /// Uses random alphanumeric characters to ensure the file won't exist. + /// + private static Gen NonExistentPathGen() + { + // Generate a path that is very unlikely to exist on disk + var charGen = Gen.Elements( + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', + 'A', 'B', 'C', 'D', 'E', 'F', '0', '1', '2', '3'); + + return from len in Gen.Choose(5, 50) + from chars in Gen.ArrayOf(charGen).Resize(len) + select @"C:\NonExistent_" + new string(chars) + ".bmp"; + } + + // ── Property Tests ────────────────────────────────────────────────── + + /// + /// Property 4.1: Null/whitespace/empty paths always return Empty. + /// For any null, empty, or whitespace-only string, Validate SHALL return Empty. + /// + [Property(MaxTest = 100)] + public Property NullOrWhitespacePaths_ReturnEmpty() + { + return Prop.ForAll( + NullOrWhitespaceGen().ToArbitrary(), + path => + { + var result = ManualImageValidator.Validate(path); + return result == ManualImageValidationResult.Empty; + }); + } + + /// + /// Property 4.2: Paths longer than 260 characters always return PathTooLong. + /// For any non-whitespace path with length > 260, Validate SHALL return PathTooLong. + /// + [Property(MaxTest = 100)] + public Property PathsLongerThan260_ReturnPathTooLong() + { + return Prop.ForAll( + PathTooLongGen().ToArbitrary(), + path => + { + var result = ManualImageValidator.Validate(path); + return result == ManualImageValidationResult.PathTooLong; + }); + } + + /// + /// Property 4.3: Non-existent file paths (non-empty, ≤260) return FileNotFound. + /// For any non-whitespace path with length ≤ 260 that does not exist on disk, + /// Validate SHALL return FileNotFound. + /// + [Property(MaxTest = 100)] + public Property NonExistentPaths_ReturnFileNotFound() + { + return Prop.ForAll( + NonExistentPathGen().ToArbitrary(), + path => + { + var result = ManualImageValidator.Validate(path); + return result == ManualImageValidationResult.FileNotFound; + }); + } + + /// + /// Property 4.4: The result is always one of the defined enum values (exhaustive classification). + /// For any arbitrary string, Validate SHALL return a value within the defined enum range. + /// + [Property(MaxTest = 100)] + public Property Result_IsAlwaysDefinedEnumValue() + { + // Generate arbitrary strings including nulls, empty, whitespace, and random paths + var charGen = Gen.Frequency( + (5, Gen.Choose(0x20, 0x7E).Select(i => (char)i)), // printable ASCII + (2, Gen.Choose(0x4E00, 0x9FFF).Select(i => (char)i)), // CJK characters + (1, Gen.Elements('\\', '/', ':', '*', '?', '"', '<', '>', '|')), + (1, Gen.Elements(' ', '\t', '\n', '\r')) + ); + + var randomStringGen = from len in Gen.Choose(0, 300) + from chars in Gen.ArrayOf(charGen).Resize(len) + select new string(chars); + + var anyStringGen = Gen.OneOf( + Gen.Constant((string)null), + Gen.Constant(string.Empty), + Gen.Constant(" "), + randomStringGen); + + return Prop.ForAll( + anyStringGen.ToArbitrary(), + path => + { + var result = ManualImageValidator.Validate(path); + return Enum.IsDefined(typeof(ManualImageValidationResult), result); + }); + } + + /// + /// Property 4.5: Existing files with supported extensions return Valid. + /// Creates temporary files with supported extensions and verifies Validate returns Valid. + /// + [Property(MaxTest = 20)] + public Property ExistingFilesWithSupportedExtension_ReturnValid() + { + var supportedExtGen = Gen.Elements(".bmp", ".png", ".tiff", ".tif"); + + return Prop.ForAll( + supportedExtGen.ToArbitrary(), + ext => + { + var tempDir = Path.Combine(Path.GetTempPath(), "ManualImageValidatorTests"); + Directory.CreateDirectory(tempDir); + var tempFile = Path.Combine(tempDir, $"test_{Guid.NewGuid():N}{ext}"); + + try + { + File.WriteAllBytes(tempFile, new byte[] { 0x00 }); + var result = ManualImageValidator.Validate(tempFile); + return result == ManualImageValidationResult.Valid; + } + finally + { + if (File.Exists(tempFile)) + File.Delete(tempFile); + } + }); + } + + /// + /// Property 4.6: Existing files with unsupported extensions return UnsupportedFormat. + /// Creates temporary files with unsupported extensions and verifies Validate returns UnsupportedFormat. + /// + [Property(MaxTest = 20)] + public Property ExistingFilesWithUnsupportedExtension_ReturnUnsupportedFormat() + { + var unsupportedExtGen = Gen.Elements(".jpg", ".jpeg", ".gif", ".svg", ".webp", ".txt", ".dat"); + + return Prop.ForAll( + unsupportedExtGen.ToArbitrary(), + ext => + { + var tempDir = Path.Combine(Path.GetTempPath(), "ManualImageValidatorTests"); + Directory.CreateDirectory(tempDir); + var tempFile = Path.Combine(tempDir, $"test_{Guid.NewGuid():N}{ext}"); + + try + { + File.WriteAllBytes(tempFile, new byte[] { 0x00 }); + var result = ManualImageValidator.Validate(tempFile); + return result == ManualImageValidationResult.UnsupportedFormat; + } + finally + { + if (File.Exists(tempFile)) + File.Delete(tempFile); + } + }); + } + } +} diff --git a/XplorePlane.Tests/Models/SummarySerializationPropertyTests.cs b/XplorePlane.Tests/Models/SummarySerializationPropertyTests.cs new file mode 100644 index 0000000..288e87f --- /dev/null +++ b/XplorePlane.Tests/Models/SummarySerializationPropertyTests.cs @@ -0,0 +1,151 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using FsCheck; +using FsCheck.Fluent; +using FsCheck.Xunit; +using XplorePlane.Models; + +namespace XplorePlane.Tests.Models +{ + /// + /// FsCheck property-based tests for BatchCaptureResult JSON serialization round-trip. + /// Property 10: 摘要 JSON 序列化往返 + /// + public class SummarySerializationPropertyTests + { + // ── Generators ─────────────────────────────────────────────────── + + /// + /// Generates a non-null string (FsCheck may generate nulls by default). + /// + private static Gen NonNullStringGen => + ArbMap.Default.GeneratorFor() + .Select(s => s.Get) + .Or(Gen.Constant(string.Empty)); + + /// + /// Generates a nullable string (either null or a non-empty string). + /// + private static Gen NullableStringGen => + Gen.OneOf( + Gen.Constant(null), + ArbMap.Default.GeneratorFor().Select(s => s.Get)); + + /// + /// Generates a random PositionResult with valid field values. + /// + private static Gen PositionResultGen => + from nodeName in NonNullStringGen + from nodeIndex in Gen.Choose(0, 1000) + from status in Gen.Elements("Success", "Failed") + from errorMessage in NullableStringGen + from imagePath in NullableStringGen + select new PositionResult + { + NodeName = nodeName, + NodeIndex = nodeIndex, + Status = status, + ErrorMessage = errorMessage, + ImagePath = imagePath + }; + + /// + /// Generates a random BatchCaptureResult with valid field values. + /// + private static Gen BatchCaptureResultGen => + from programName in NonNullStringGen + from startTime in NonNullStringGen + from durationSeconds in Gen.Choose(0, 100000).Select(i => i / 100.0) + from totalPositions in Gen.Choose(0, 50) + from succeededPositions in Gen.Choose(0, 50) + from failedPositions in Gen.Choose(0, 50) + from savedImageCount in Gen.Choose(0, 100) + from status in Gen.Elements("Completed", "Cancelled") + from completedBeforeCancel in Gen.OneOf( + Gen.Constant(null), + Gen.Choose(0, 50).Select(i => (int?)i)) + from notExecutedAfterCancel in Gen.OneOf( + Gen.Constant(null), + Gen.Choose(0, 50).Select(i => (int?)i)) + from positionCount in Gen.Choose(0, 5) + from positions in Gen.ListOf(PositionResultGen, positionCount) + select new BatchCaptureResult + { + ProgramName = programName, + StartTime = startTime, + DurationSeconds = durationSeconds, + TotalPositions = totalPositions, + SucceededPositions = succeededPositions, + FailedPositions = failedPositions, + SavedImageCount = savedImageCount, + Status = status, + CompletedBeforeCancel = completedBeforeCancel, + NotExecutedAfterCancel = notExecutedAfterCancel, + Positions = positions.ToList() + }; + + // ── Property 10 ───────────────────────────────────────────────── + + /// + /// **Validates: Requirements 4.3** + /// + /// For any valid BatchCaptureResult object, serializing to JSON and + /// deserializing back produces an object with identical field values. + /// + [Property(MaxTest = 100)] + public Property BatchCaptureResult_JsonRoundTrip_PreservesAllFields() + { + return Prop.ForAll( + BatchCaptureResultGen.ToArbitrary(), + original => + { + // Serialize to JSON + var json = JsonSerializer.Serialize(original); + + // Deserialize back + var deserialized = JsonSerializer.Deserialize(json); + + // Verify all scalar properties + bool scalarFieldsMatch = + deserialized.ProgramName == original.ProgramName && + deserialized.StartTime == original.StartTime && + deserialized.DurationSeconds == original.DurationSeconds && + deserialized.TotalPositions == original.TotalPositions && + deserialized.SucceededPositions == original.SucceededPositions && + deserialized.FailedPositions == original.FailedPositions && + deserialized.SavedImageCount == original.SavedImageCount && + deserialized.Status == original.Status; + + // Verify nullable int properties + bool nullableFieldsMatch = + deserialized.CompletedBeforeCancel == original.CompletedBeforeCancel && + deserialized.NotExecutedAfterCancel == original.NotExecutedAfterCancel; + + // Verify list property count + bool listCountMatch = + deserialized.Positions.Count == original.Positions.Count; + + // Verify each position in the list + bool positionsMatch = true; + for (int i = 0; i < original.Positions.Count; i++) + { + var orig = original.Positions[i]; + var deser = deserialized.Positions[i]; + if (deser.NodeName != orig.NodeName || + deser.NodeIndex != orig.NodeIndex || + deser.Status != orig.Status || + deser.ErrorMessage != orig.ErrorMessage || + deser.ImagePath != orig.ImagePath) + { + positionsMatch = false; + break; + } + } + + return scalarFieldsMatch && nullableFieldsMatch && + listCountMatch && positionsMatch; + }); + } + } +} diff --git a/XplorePlane.Tests/Services/CancellationPropertyTests.cs b/XplorePlane.Tests/Services/CancellationPropertyTests.cs new file mode 100644 index 0000000..bd5f4be --- /dev/null +++ b/XplorePlane.Tests/Services/CancellationPropertyTests.cs @@ -0,0 +1,225 @@ +#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; + }); + } + } +} diff --git a/XplorePlane.Tests/Services/CancellationSummaryPropertyTests.cs b/XplorePlane.Tests/Services/CancellationSummaryPropertyTests.cs new file mode 100644 index 0000000..45a1215 --- /dev/null +++ b/XplorePlane.Tests/Services/CancellationSummaryPropertyTests.cs @@ -0,0 +1,220 @@ +#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; + }); + } + } +} diff --git a/XplorePlane.Tests/Services/CncExecutionServiceTests.cs b/XplorePlane.Tests/Services/CncExecutionServiceTests.cs index 273a98f..5611f24 100644 --- a/XplorePlane.Tests/Services/CncExecutionServiceTests.cs +++ b/XplorePlane.Tests/Services/CncExecutionServiceTests.cs @@ -190,6 +190,7 @@ internal sealed class SynchronousProgress : IProgress 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 @@ -222,7 +223,8 @@ internal sealed class SynchronousProgress : IProgress mockAppStateService.Object, mockPipelineExecutionService.Object, mockImageProcessingService.Object, - mockEventAggregator.Object); + mockEventAggregator.Object, + mockImagePersistenceService.Object); return (service, mockStore, mockLogger, mockMainViewportService, mockAppStateService); } diff --git a/XplorePlane.Tests/Services/ExecutionOrderPropertyTests.cs b/XplorePlane.Tests/Services/ExecutionOrderPropertyTests.cs new file mode 100644 index 0000000..56268fb --- /dev/null +++ b/XplorePlane.Tests/Services/ExecutionOrderPropertyTests.cs @@ -0,0 +1,204 @@ +#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 5: 多位置按 Index 升序执行 + /// **Validates: Requirements 3.1** + /// + /// For any CNC program containing multiple SavePositionNodes with arbitrary Index values, + /// the CNC_Execution_Service SHALL process them in strictly ascending Index order, + /// and progress reports SHALL reflect this ordering. + /// + public class ExecutionOrderPropertyTests + { + /// + /// Creates a CncExecutionService with all dependencies mocked. + /// The MainViewportService returns a valid BitmapSource so execution proceeds. + /// + private static (CncExecutionService Service, Mock MainViewport) CreateServiceWithImage() + { + 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); + + // ImagePersistenceService - return success for SaveImageAsync + mockImagePersistenceService + .Setup(s => s.SaveImageAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new ImageSaveResult(true, "C:\\test\\image.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, mockMainViewportService); + } + + /// + /// FsCheck generator for a list of SavePositionNodes with random, unique Index values. + /// Generates 2-10 nodes with shuffled indices to ensure non-sequential ordering in the input. + /// + private static Arbitrary> SavePositionNodesArb() + { + var gen = + from count in Gen.Choose(2, 10) + from indices in Gen.Shuffle(Enumerable.Range(0, 100).ToArray()) + .Select(arr => arr.Take(count).ToList()) + select indices.Select(idx => + new SavePositionNode( + Guid.NewGuid(), + idx, + $"Position_{idx}", + MotionState.Default, + SaveImage: false, + ManualImagePath: "")) + .ToList(); + + return gen.ToArbitrary(); + } + + // Feature: cnc-multi-position-image-capture, Property 5: 多位置按 Index 升序执行 + // **Validates: Requirements 3.1** + [Property(MaxTest = 100)] + public Property MultiPosition_ExecutedInStrictlyAscendingIndexOrder() + { + return Prop.ForAll( + SavePositionNodesArb(), + savePositionNodes => + { + var (service, _) = CreateServiceWithImage(); + + // Build a CncProgram containing only the generated SavePositionNodes + // (in their original random order - the service should sort by Index) + var program = new CncProgram( + Guid.NewGuid(), + "TestProgram", + DateTime.UtcNow, + DateTime.UtcNow, + savePositionNodes.Cast().ToList().AsReadOnly()); + + // Capture progress reports to verify execution order + var reportedPositionIndices = new List(); + var reportedNodeIds = new List(); + var progress = new SynchronousProgress(p => + { + // Capture the first Running report for each node (the initial progress report) + if (p.State == NodeExecutionState.Running && p.PositionIndex.HasValue) + { + reportedNodeIds.Add(p.NodeId); + reportedPositionIndices.Add(p.PositionIndex.Value); + } + }); + + service.ExecuteAsync(program, progress, CancellationToken.None) + .GetAwaiter().GetResult(); + + // Build a map from NodeId to Index + var nodeIndexMap = savePositionNodes.ToDictionary(n => n.Id, n => n.Index); + + // Verify 1: All nodes were executed + if (reportedNodeIds.Count != savePositionNodes.Count) + return false; + + // Verify 2: Nodes were processed in strictly ascending Index order + var executedIndices = reportedNodeIds + .Select(id => nodeIndexMap[id]) + .ToList(); + + for (int i = 1; i < executedIndices.Count; i++) + { + if (executedIndices[i] <= executedIndices[i - 1]) + return false; + } + + // Verify 3: Progress report PositionIndex values are strictly ascending from 0 + for (int i = 0; i < reportedPositionIndices.Count; i++) + { + if (reportedPositionIndices[i] != i) + return false; + } + + return true; + }); + } + } +} diff --git a/XplorePlane.Tests/Services/ExecutionResiliencePropertyTests.cs b/XplorePlane.Tests/Services/ExecutionResiliencePropertyTests.cs new file mode 100644 index 0000000..f6ae33d --- /dev/null +++ b/XplorePlane.Tests/Services/ExecutionResiliencePropertyTests.cs @@ -0,0 +1,263 @@ +#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; + }); + } + } +} diff --git a/XplorePlane.Tests/Services/ImagePassingPropertyTests.cs b/XplorePlane.Tests/Services/ImagePassingPropertyTests.cs new file mode 100644 index 0000000..5cfb310 --- /dev/null +++ b/XplorePlane.Tests/Services/ImagePassingPropertyTests.cs @@ -0,0 +1,339 @@ +#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 XplorePlane.ViewModels; +using Xunit; + +namespace XplorePlane.Tests.Services +{ + /// + /// Property 6: 采集图像传递给紧邻的 InspectionModuleNode + /// Feature: cnc-multi-position-image-capture + /// **Validates: Requirements 3.2** + /// + /// For any CNC program where a SavePositionNode is immediately followed (by Index order) + /// by an InspectionModuleNode, the image acquired at that SavePositionNode SHALL be passed + /// as the source image to the InspectionModuleNode's pipeline execution. + /// + public class ImagePassingPropertyTests + { + // ── Helper: Create a 1x1 BitmapSource for testing ────────────────────── + + private static BitmapSource CreateTestBitmap() + { + var bitmap = new WriteableBitmap(1, 1, 96, 96, PixelFormats.Bgr32, null); + bitmap.Freeze(); + return bitmap; + } + + // ── Generator: CNC programs with SavePositionNodes optionally followed by InspectionModuleNodes ── + + private static Gen NonEmptyStringGen => + ArbMap.Default.GeneratorFor().Select(s => s.Get); + + private static Gen PipelineGen => + from name in NonEmptyStringGen + select new PipelineModel + { + Name = name, + Nodes = new List + { + new PipelineNodeModel { OperatorKey = "test_op", Order = 0, IsEnabled = true } + } + }; + + /// + /// Generates a CNC program where some SavePositionNodes are followed by InspectionModuleNodes + /// and some are not. Returns the program along with metadata about which pairs exist. + /// + private static Gen CncProgramWithMixedFollowersGen() + { + return from saveCount in Gen.Choose(1, 5) + from followFlags in Gen.ListOf(ArbMap.Default.GeneratorFor(), saveCount) + from programName in NonEmptyStringGen + let nodes = BuildMixedNodes(saveCount, followFlags) + select new CncProgram( + Guid.NewGuid(), + programName, + DateTime.UtcNow, + DateTime.UtcNow, + nodes.AsReadOnly()); + } + + private static List BuildMixedNodes(int saveCount, IReadOnlyList followFlags) + { + var nodes = new List(); + int index = 0; + + for (int i = 0; i < saveCount; i++) + { + // Add a SavePositionNode + var spNode = new SavePositionNode( + Guid.NewGuid(), + index, + $"Position_{i}", + MotionState.Default, + SaveImage: false, + ManualImagePath: ""); + nodes.Add(spNode); + index++; + + // Optionally follow with an InspectionModuleNode + if (i < followFlags.Count && followFlags[i]) + { + var pipeline = new PipelineModel + { + Name = $"Pipeline_{i}", + Nodes = new List + { + new PipelineNodeModel { OperatorKey = "test_op", Order = 0, IsEnabled = true } + } + }; + var inspNode = new InspectionModuleNode( + Guid.NewGuid(), + index, + $"Inspect_{i}", + pipeline); + nodes.Add(inspNode); + index++; + } + } + + return nodes; + } + + // ── Service Factory ────────────────────────────────────────────────────── + + private static ( + CncExecutionService Service, + Mock PipelineExec, + Mock MainViewport, + Mock ImagePersistence, + Mock Logger) + CreateServiceWithCapture(BitmapSource detectorImage) + { + 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()); + + // Return the detector image from the viewport service (simulates detector acquisition) + mockMainViewportService + .Setup(m => m.CurrentDisplayImage) + .Returns(detectorImage); + + 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); + + mockImagePersistenceService + .Setup(s => s.SaveImageAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new ImageSaveResult(true, "test.bmp", 100)); + + 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, mockPipelineExecutionService, mockMainViewportService, mockImagePersistenceService, mockLogger); + } + + // ── Property 6: 采集图像传递给紧邻的 InspectionModuleNode ────────────── + + // Feature: cnc-multi-position-image-capture, Property 6: 采集图像传递给紧邻的 InspectionModuleNode + // Validates: Requirements 3.2 + [Property(MaxTest = 100)] + public Property AcquiredImage_PassedToFollowingInspectionModuleNode() + { + return Prop.ForAll( + CncProgramWithMixedFollowersGen().ToArbitrary(), + program => + { + var detectorImage = CreateTestBitmap(); + var (service, mockPipelineExec, _, _, _) = CreateServiceWithCapture(detectorImage); + + // Track all pipeline execution calls with their source images + var pipelineCalls = new List<(Guid NodeId, BitmapSource SourceImage)>(); + + mockPipelineExec + .Setup(p => p.ExecutePipelineAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Callback, BitmapSource, IProgress, CancellationToken>( + (nodes, source, progress, ct) => + { + // We can't easily get the NodeId from here, but we track the source image + pipelineCalls.Add((Guid.Empty, source)); + }) + .ReturnsAsync(detectorImage); + + service.ExecuteAsync(program, null, CancellationToken.None) + .GetAwaiter().GetResult(); + + // Determine expected behavior from the program structure + var allNodesOrdered = program.Nodes.OrderBy(n => n.Index).ToList(); + var savePositionNodes = allNodesOrdered.OfType().ToList(); + + int expectedPipelineCalls = 0; + foreach (var sp in savePositionNodes) + { + int spOrderIndex = allNodesOrdered.FindIndex(n => n.Id == sp.Id); + if (spOrderIndex >= 0 && spOrderIndex + 1 < allNodesOrdered.Count) + { + var nextNode = allNodesOrdered[spOrderIndex + 1]; + if (nextNode is InspectionModuleNode) + expectedPipelineCalls++; + } + } + + // Verify: pipeline was called exactly for each SavePositionNode followed by InspectionModuleNode + bool correctCallCount = pipelineCalls.Count == expectedPipelineCalls; + + // Verify: each pipeline call received a non-null source image (the detector image) + bool allReceivedImage = pipelineCalls.All(c => c.SourceImage != null); + + // Verify: each pipeline call received the same detector image + bool allReceivedCorrectImage = pipelineCalls.All(c => + ReferenceEquals(c.SourceImage, detectorImage)); + + return correctCallCount && allReceivedImage && allReceivedCorrectImage; + }); + } + + // ── Property 6 (negative case): SavePositionNode NOT followed by InspectionModuleNode ── + + // Feature: cnc-multi-position-image-capture, Property 6 (negative): No pipeline execution for non-followed SavePositionNodes + // Validates: Requirements 3.2 + [Property(MaxTest = 100)] + public Property NoPipelineExecution_WhenNoFollowingInspectionModuleNode() + { + // Generate programs where NO SavePositionNode is followed by an InspectionModuleNode + var gen = from saveCount in Gen.Choose(1, 5) + from programName in NonEmptyStringGen + let nodes = BuildNodesWithoutFollowingInspection(saveCount) + select new CncProgram( + Guid.NewGuid(), + programName, + DateTime.UtcNow, + DateTime.UtcNow, + nodes.AsReadOnly()); + + return Prop.ForAll( + gen.ToArbitrary(), + program => + { + var detectorImage = CreateTestBitmap(); + var (service, mockPipelineExec, _, _, _) = CreateServiceWithCapture(detectorImage); + + int pipelineCallCount = 0; + mockPipelineExec + .Setup(p => p.ExecutePipelineAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Callback, BitmapSource, IProgress, CancellationToken>( + (_, _, _, _) => Interlocked.Increment(ref pipelineCallCount)) + .ReturnsAsync(detectorImage); + + service.ExecuteAsync(program, null, CancellationToken.None) + .GetAwaiter().GetResult(); + + // No pipeline execution should occur for SavePositionNodes + // (InspectionModuleNodes in the general loop may still trigger pipeline, + // but since we only have SavePositionNodes and non-inspection nodes, count should be 0) + return pipelineCallCount == 0; + }); + } + + private static List BuildNodesWithoutFollowingInspection(int saveCount) + { + var nodes = new List(); + int index = 0; + + for (int i = 0; i < saveCount; i++) + { + // Add a SavePositionNode + nodes.Add(new SavePositionNode( + Guid.NewGuid(), + index, + $"Position_{i}", + MotionState.Default, + SaveImage: false, + ManualImagePath: "")); + index++; + + // Follow with a non-inspection node (e.g., WaitDelayNode with 0ms) + nodes.Add(new WaitDelayNode( + Guid.NewGuid(), + index, + $"Wait_{i}", + 0)); + index++; + } + + return nodes; + } + } +} diff --git a/XplorePlane.Tests/Services/ImagePersistenceCallPropertyTests.cs b/XplorePlane.Tests/Services/ImagePersistenceCallPropertyTests.cs new file mode 100644 index 0000000..528a583 --- /dev/null +++ b/XplorePlane.Tests/Services/ImagePersistenceCallPropertyTests.cs @@ -0,0 +1,301 @@ +#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.Cast().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.Cast().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.Cast().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; + }); + } + } +} diff --git a/XplorePlane.Tests/Services/ImagePersistenceServiceTests.cs b/XplorePlane.Tests/Services/ImagePersistenceServiceTests.cs new file mode 100644 index 0000000..c06d2fb --- /dev/null +++ b/XplorePlane.Tests/Services/ImagePersistenceServiceTests.cs @@ -0,0 +1,294 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using XP.Common.Logging.Interfaces; +using XplorePlane.Models; +using XplorePlane.Services.Cnc; +using XplorePlane.Services.Storage; +using Xunit; + +namespace XplorePlane.Tests.Services +{ + /// + /// Unit tests for ImagePersistenceService. + /// Validates: Requirements 1.3, 1.5, 1.7, 4.3 + /// + public class ImagePersistenceServiceTests : IDisposable + { + private readonly string _tempDir; + private readonly Mock _mockDataPathService; + private readonly Mock _mockLogger; + private readonly ImagePersistenceService _service; + + public ImagePersistenceServiceTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "ImagePersistenceTests_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + + _mockDataPathService = new Mock(); + _mockDataPathService.Setup(s => s.DataPath).Returns(_tempDir); + + _mockLogger = new Mock(); + _mockLogger.Setup(l => l.ForModule()).Returns(_mockLogger.Object); + + _service = new ImagePersistenceService(_mockDataPathService.Object, _mockLogger.Object); + } + + public void Dispose() + { + try + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, recursive: true); + } + catch + { + // Best-effort cleanup + } + } + + // ── Test: Directory auto-creation ──────────────────────────────────── + + [Fact] + public async Task SaveImageAsync_CreatesDirectory_WhenItDoesNotExist() + { + // Arrange: use a sub-path that doesn't exist yet + var nestedDir = Path.Combine(_tempDir, "nested", "deep"); + _mockDataPathService.Setup(s => s.DataPath).Returns(nestedDir); + var service = new ImagePersistenceService(_mockDataPathService.Object, _mockLogger.Object); + + var imageData = new byte[] { 0x42, 0x4D, 0x00, 0x01 }; // minimal data + var programName = "TestProgram"; + var nodeName = "Position1"; + + // Act + var result = await service.SaveImageAsync(imageData, nodeName, programName); + + // Assert + Assert.True(result.Success); + Assert.True(Directory.Exists(Path.GetDirectoryName(result.FilePath))); + Assert.True(File.Exists(result.FilePath)); + } + + // ── Test: Successful save returns correct ImageSaveResult ───────────── + + [Fact] + public async Task SaveImageAsync_ReturnsCorrectResult_OnSuccess() + { + // Arrange + var imageData = new byte[1024]; + new Random(42).NextBytes(imageData); + var programName = "MyProgram"; + var nodeName = "Node_A"; + + // Act + var result = await _service.SaveImageAsync(imageData, nodeName, programName); + + // Assert + Assert.True(result.Success); + Assert.NotNull(result.FilePath); + Assert.Contains(nodeName, result.FilePath); + Assert.EndsWith(".bmp", result.FilePath); + Assert.Equal(1024, result.FileSizeBytes); + Assert.Null(result.ErrorMessage); + + // Verify file actually exists with correct content + var savedBytes = await File.ReadAllBytesAsync(result.FilePath); + Assert.Equal(imageData, savedBytes); + } + + // ── Test: Timeout returns failure result ────────────────────────────── + + [Fact] + public async Task SaveImageAsync_ReturnsFailure_WhenCancelled() + { + // Arrange: pre-cancel the token to simulate timeout/cancellation scenario + var imageData = new byte[] { 0x01, 0x02, 0x03 }; + using var cts = new CancellationTokenSource(); + cts.Cancel(); // Pre-cancel to trigger OperationCanceledException immediately + + // Act + var result = await _service.SaveImageAsync(imageData, "TestNode", "TestProgram", cts.Token); + + // Assert + Assert.False(result.Success); + Assert.Null(result.FilePath); + Assert.Equal(0, result.FileSizeBytes); + Assert.NotNull(result.ErrorMessage); + } + + [Fact] + public async Task SaveImageAsync_ReturnsFailure_WhenTimeoutOccurs() + { + // Arrange: Use a read-only directory to force an IOException, simulating a timeout-like failure + // We test the timeout path by using a very short internal timeout via a linked token + // Since we can't easily trigger a real 10s timeout in a unit test, + // we verify the cancellation path works correctly with a pre-cancelled external token + // and verify the error message format for the external cancellation path. + + // To truly test the timeout path, we'd need to mock File.WriteAllBytesAsync, + // but since the service uses static File methods, we test the cancellation behavior instead. + // The timeout mechanism uses CancellationTokenSource.CreateLinkedTokenSource + CancelAfter(10s). + + // Verify that when external cancellation is NOT requested but operation is cancelled + // (simulating internal timeout), the error message mentions timeout. + // We achieve this by making the directory path invalid so the write fails. + var invalidPath = Path.Combine(_tempDir, new string('x', 300)); // Path too long + _mockDataPathService.Setup(s => s.DataPath).Returns(invalidPath); + var service = new ImagePersistenceService(_mockDataPathService.Object, _mockLogger.Object); + + var imageData = new byte[] { 0x01, 0x02, 0x03 }; + + // Act + var result = await service.SaveImageAsync(imageData, "TestNode", "TestProgram"); + + // Assert - should fail gracefully (IOException path) + Assert.False(result.Success); + Assert.NotNull(result.ErrorMessage); + Assert.Equal(0, result.FileSizeBytes); + } + + // ── Test: WriteSummaryAsync generates valid JSON ────────────────────── + + [Fact] + public async Task WriteSummaryAsync_GeneratesValidJson() + { + // Arrange + var summary = new BatchCaptureResult + { + ProgramName = "TestProgram", + StartTime = "2025-01-15T14:30:25", + DurationSeconds = 45.5, + TotalPositions = 3, + SucceededPositions = 2, + FailedPositions = 1, + SavedImageCount = 2, + Status = "Completed", + Positions = new List + { + new PositionResult { NodeName = "Pos1", NodeIndex = 0, Status = "Success", ImagePath = @"C:\img1.bmp" }, + new PositionResult { NodeName = "Pos2", NodeIndex = 1, Status = "Success", ImagePath = @"C:\img2.bmp" }, + new PositionResult { NodeName = "Pos3", NodeIndex = 2, Status = "Failed", ErrorMessage = "Detector timeout" } + } + }; + var programName = "TestProgram"; + + // Act + var success = await _service.WriteSummaryAsync(summary, programName); + + // Assert + Assert.True(success); + + // Verify the JSON file exists and can be deserialized back + var directory = _service.GetBatchCaptureDirectory(programName); + var summaryPath = Path.Combine(directory, "summary.json"); + Assert.True(File.Exists(summaryPath)); + + var json = await File.ReadAllTextAsync(summaryPath); + var deserialized = JsonSerializer.Deserialize(json); + + Assert.NotNull(deserialized); + Assert.Equal(summary.ProgramName, deserialized.ProgramName); + Assert.Equal(summary.StartTime, deserialized.StartTime); + Assert.Equal(summary.DurationSeconds, deserialized.DurationSeconds); + Assert.Equal(summary.TotalPositions, deserialized.TotalPositions); + Assert.Equal(summary.SucceededPositions, deserialized.SucceededPositions); + Assert.Equal(summary.FailedPositions, deserialized.FailedPositions); + Assert.Equal(summary.SavedImageCount, deserialized.SavedImageCount); + Assert.Equal(summary.Status, deserialized.Status); + Assert.Equal(3, deserialized.Positions.Count); + Assert.Equal("Pos1", deserialized.Positions[0].NodeName); + Assert.Equal("Failed", deserialized.Positions[2].Status); + } + + // ── Test: WriteSummaryAsync returns false on I/O failure ────────────── + + [Fact] + public async Task WriteSummaryAsync_ReturnsFalse_OnIOFailure() + { + // Arrange: use an invalid path that will cause I/O failure + var invalidPath = Path.Combine(_tempDir, new string('x', 300)); + _mockDataPathService.Setup(s => s.DataPath).Returns(invalidPath); + var service = new ImagePersistenceService(_mockDataPathService.Object, _mockLogger.Object); + + var summary = new BatchCaptureResult + { + ProgramName = "TestProgram", + TotalPositions = 1, + Positions = new List() + }; + + // Act + var result = await service.WriteSummaryAsync(summary, "TestProgram"); + + // Assert - should return false, not throw + Assert.False(result); + + // Verify error was logged + _mockLogger.Verify( + l => l.Error(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Once); + } + + // ── Test: GetBatchCaptureDirectory returns correct path format ──────── + + [Fact] + public void GetBatchCaptureDirectory_ReturnsCorrectPathFormat() + { + // Arrange + var programName = "MyProgram"; + var expectedDate = DateTime.Now.ToString("yyyy-MM-dd"); + + // Act + var result = _service.GetBatchCaptureDirectory(programName); + + // Assert + Assert.Contains("CapturedImages", result); + Assert.Contains(expectedDate, result); + Assert.Contains(programName, result); + Assert.StartsWith(_tempDir, result); + } + + // ── Test: SaveImageAsync sanitizes node name in file path ───────────── + + [Fact] + public async Task SaveImageAsync_SanitizesNodeName_InFilePath() + { + // Arrange + var imageData = new byte[] { 0x42, 0x4D }; + var unsafeNodeName = "Node:With*Invalid?Chars"; + + // Act + var result = await _service.SaveImageAsync(imageData, unsafeNodeName, "Program1"); + + // Assert + Assert.True(result.Success); + var fileName = Path.GetFileName(result.FilePath); + Assert.DoesNotContain(":", fileName); + Assert.DoesNotContain("*", fileName); + Assert.DoesNotContain("?", fileName); + Assert.Contains("Node_With_Invalid_Chars", fileName); + } + + // ── Test: SaveImageAsync logs on success ────────────────────────────── + + [Fact] + public async Task SaveImageAsync_LogsInfo_OnSuccess() + { + // Arrange + var imageData = new byte[512]; + + // Act + await _service.SaveImageAsync(imageData, "TestNode", "TestProgram"); + + // Assert + _mockLogger.Verify( + l => l.Info(It.IsAny(), It.IsAny()), + Times.Once); + } + } +} diff --git a/XplorePlane.Tests/Services/ProgressReportPropertyTests.cs b/XplorePlane.Tests/Services/ProgressReportPropertyTests.cs new file mode 100644 index 0000000..29cf69d --- /dev/null +++ b/XplorePlane.Tests/Services/ProgressReportPropertyTests.cs @@ -0,0 +1,209 @@ +#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 7: 进度报告包含正确的索引和总数 + /// **Validates: Requirements 3.4** + /// + /// For any CNC program with N SavePositionNodes, the CNC_Execution_Service SHALL report + /// progress N times (or fewer if cancelled), each report containing a 0-based position index + /// and the total count N, with indices strictly increasing from 0. + /// + public class ProgressReportPropertyTests + { + private static (CncExecutionService Service, Mock MainViewport) + CreateServiceWithImage() + { + 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 valid image so that image acquisition succeeds + var dummyImage = CreateDummyBitmap(); + mockMainViewportService.SetupGet(m => m.LatestManualImage).Returns((ImageSource)null); + mockMainViewportService.SetupGet(m => m.CurrentDisplayImage).Returns(dummyImage); + + // Image persistence returns success + mockImagePersistenceService.Setup(s => s.SaveImageAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new ImageSaveResult(true, @"C:\test\image.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, mockMainViewportService); + } + + private static BitmapSource CreateDummyBitmap() + { + var stride = 4 * 4; // 4 pixels wide, 4 bytes per pixel + var pixels = new byte[stride * 4]; // 4x4 image + var bitmap = BitmapSource.Create(4, 4, 96, 96, PixelFormats.Bgra32, null, pixels, stride); + bitmap.Freeze(); + return bitmap; + } + + /// + /// Generates a CncProgram with N SavePositionNodes (N between 1 and 10), + /// each with unique ascending indices. + /// + private static Arbitrary SavePositionProgramArb() + { + var gen = + from count in Gen.Choose(1, 10) + from name in ArbMap.Default.GeneratorFor().Select(s => s.Get) + from nodes in GenSavePositionNodes(count) + select new CncProgram( + Guid.NewGuid(), name, + DateTime.UtcNow, DateTime.UtcNow, + nodes.AsReadOnly()); + return gen.ToArbitrary(); + } + + 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 nodeName in ArbMap.Default.GeneratorFor().Select(s => s.Get) + let node = new SavePositionNode( + Guid.NewGuid(), idx, $"Pos_{nodeName}_{idx}", + MotionState.Default, SaveImage: false) + select new List(list) { node }; + } + return acc; + } + + // ── Property 7: 进度报告包含正确的索引和总数 ────────────────────────── + + // Feature: cnc-multi-position-image-capture, Property 7: 进度报告包含正确的索引和总数 + // Validates: Requirements 3.4 + [Property(MaxTest = 100)] + public Property ProgressReports_ContainCorrectIndexAndTotal() + { + return Prop.ForAll( + SavePositionProgramArb(), + program => + { + var (service, _) = CreateServiceWithImage(); + + var savePositionCount = program.Nodes.OfType().Count(); + + // Capture all progress reports that have PositionIndex set (Running state) + var runningReports = new List<(int PositionIndex, int TotalPositions)>(); + var progress = new SynchronousProgress(p => + { + if (p.State == NodeExecutionState.Running + && p.PositionIndex.HasValue + && p.TotalPositions.HasValue) + { + runningReports.Add((p.PositionIndex.Value, p.TotalPositions.Value)); + } + }); + + service.ExecuteAsync(program, progress, CancellationToken.None) + .GetAwaiter().GetResult(); + + // Verify: at least N Running progress reports (one per position) + if (runningReports.Count < savePositionCount) + return false; + + // Verify: all TotalPositions values equal N + if (runningReports.Any(r => r.TotalPositions != savePositionCount)) + return false; + + // Verify: PositionIndex values in Running reports are strictly increasing from 0 + // Extract unique position indices in order of first appearance + var seenIndices = new List(); + foreach (var report in runningReports) + { + if (seenIndices.Count == 0 || seenIndices.Last() != report.PositionIndex) + seenIndices.Add(report.PositionIndex); + } + + // Must have exactly N distinct position indices + if (seenIndices.Count != savePositionCount) + return false; + + // Indices must be 0, 1, 2, ..., N-1 + for (int i = 0; i < savePositionCount; i++) + { + if (seenIndices[i] != i) + return false; + } + + return true; + }); + } + } +} diff --git a/XplorePlane.Tests/Services/SummaryCorrectnessPropertyTests.cs b/XplorePlane.Tests/Services/SummaryCorrectnessPropertyTests.cs new file mode 100644 index 0000000..80b0721 --- /dev/null +++ b/XplorePlane.Tests/Services/SummaryCorrectnessPropertyTests.cs @@ -0,0 +1,384 @@ +#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 9: 运行摘要字段完整性与一致性 + /// Validates: Requirements 4.1, 4.2 + /// + /// For any completed (non-cancelled) batch execution with known position results, + /// the generated BatchCaptureResult SHALL satisfy: + /// SucceededPositions + FailedPositions == TotalPositions, + /// and a position is marked "Success" if and only if its image capture completed + /// AND its inspection pipeline (if present) executed without error. + /// + public class SummaryCorrectnessPropertyTests + { + /// + /// Creates a CncExecutionService with mocks configured for multi-position testing. + /// Returns the service and the mock for IImagePersistenceService to capture WriteSummaryAsync calls. + /// The failurePattern parameter controls which positions fail image acquisition (detector returns null). + /// The pipelineFailurePattern controls which positions fail pipeline execution. + /// + private static (CncExecutionService Service, Mock ImagePersistence) + CreateServiceWithFailurePatterns( + bool[] imageAcquisitionFailures, + bool[] pipelineFailures) + { + 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); + + // Track which position index is being processed to control failure patterns + int callIndex = 0; + + // Control image acquisition: when imageAcquisitionFailures[i] is true, + // the detector returns null for that position (causing failure). + // We use LatestManualImage to provide/deny images. + mockMainViewportService + .Setup(m => m.LatestManualImage) + .Returns(() => + { + // Return null to simulate detector failure for positions marked to fail + return null; + }); + + mockMainViewportService + .Setup(m => m.CurrentDisplayImage) + .Returns(() => null); + + // Use AppStateService.LatestDetectorFrame to control image availability per position + // We'll use a sequence-based approach via callback + var positionCallCount = 0; + mockAppStateService + .Setup(a => a.LatestDetectorFrame) + .Returns(() => + { + int currentPos = positionCallCount++; + if (currentPos < imageAcquisitionFailures.Length && imageAcquisitionFailures[currentPos]) + { + // Return null frame to simulate acquisition failure + return null; + } + // Return a valid frame + return new DetectorFrame + { + ImageData = new ushort[4], + Width = 2, + Height = 2 + }; + }); + + // Control pipeline execution: throw exception for positions marked to fail + var pipelineCallCount = 0; + mockPipelineExecutionService + .Setup(p => p.ExecutePipelineAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns, BitmapSource, CancellationToken>((nodes, img, ct) => + { + int currentPipelinePos = pipelineCallCount++; + if (currentPipelinePos < pipelineFailures.Length && pipelineFailures[currentPipelinePos]) + { + throw new Exception("Simulated pipeline failure"); + } + return Task.FromResult(img); + }); + + // Image persistence always succeeds + mockImagePersistenceService + .Setup(s => s.SaveImageAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new ImageSaveResult(true, "C:\\test\\image.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); + } + + /// + /// Generates a list of SavePositionNodes with optional trailing InspectionModuleNodes. + /// + private static Gen<(List Nodes, bool[] HasInspection)> GenSavePositionProgram(int count) + { + return from hasInspections in Gen.ListOf(count, ArbMap.Default.GeneratorFor()) + let nodes = BuildNodes(count, hasInspections.ToArray()) + select (nodes, hasInspections.ToArray()); + } + + private static List BuildNodes(int positionCount, bool[] hasInspection) + { + var nodes = new List(); + int index = 0; + for (int i = 0; i < positionCount; i++) + { + var spNode = new SavePositionNode( + Guid.NewGuid(), + index, + $"Position_{i}", + MotionState.Default, + SaveImage: true, + ManualImagePath: ""); + nodes.Add(spNode); + index++; + + if (i < hasInspection.Length && hasInspection[i]) + { + var inspNode = new InspectionModuleNode( + Guid.NewGuid(), + index, + $"Inspect_{i}", + new PipelineModel { Name = $"Pipeline_{i}" }); + nodes.Add(inspNode); + index++; + } + } + return nodes; + } + + // ── Property 9: 运行摘要字段完整性与一致性 ────────────────────────────── + + /// + /// **Validates: Requirements 4.1, 4.2** + /// + /// For any completed (non-cancelled) batch execution: + /// SucceededPositions + FailedPositions == TotalPositions == N + /// + [Property(MaxTest = 100)] + public Property Summary_SucceededPlusFailedEqualsTotalPositions() + { + // Generate 1-8 positions with random failure patterns + var gen = + from posCount in Gen.Choose(1, 8) + from imageFailures in Gen.ListOf(posCount, Gen.Elements(true, false)) + from pipelineFailures in Gen.ListOf(posCount, Gen.Elements(true, false)) + from hasInspections in Gen.ListOf(posCount, ArbMap.Default.GeneratorFor()) + select new + { + PositionCount = posCount, + ImageFailures = imageFailures.ToArray(), + PipelineFailures = pipelineFailures.ToArray(), + HasInspections = hasInspections.ToArray() + }; + + return Prop.ForAll( + gen.ToArbitrary(), + testCase => + { + var nodes = BuildNodes(testCase.PositionCount, testCase.HasInspections); + var program = new CncProgram( + Guid.NewGuid(), + "TestProgram", + DateTime.UtcNow, + DateTime.UtcNow, + nodes.AsReadOnly()); + + // Build pipeline failure pattern: only positions with inspections can have pipeline failures + // Map pipeline failures to only those positions that have an inspection node following them + var effectivePipelineFailures = new bool[testCase.PositionCount]; + for (int i = 0; i < testCase.PositionCount; i++) + { + effectivePipelineFailures[i] = testCase.HasInspections[i] && testCase.PipelineFailures[i]; + } + + var (service, mockImagePersistence) = CreateServiceWithFailurePatterns( + testCase.ImageFailures, + effectivePipelineFailures); + + // Capture the BatchCaptureResult passed to WriteSummaryAsync + BatchCaptureResult capturedResult = null; + mockImagePersistence + .Setup(s => s.WriteSummaryAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback((result, _, _) => + { + capturedResult = result; + }) + .ReturnsAsync(true); + + service.ExecuteAsync(program, null, CancellationToken.None) + .GetAwaiter().GetResult(); + + // Verify invariants + if (capturedResult == null) + return false; + + // Invariant 1: SucceededPositions + FailedPositions == TotalPositions + bool sumCorrect = capturedResult.SucceededPositions + capturedResult.FailedPositions + == capturedResult.TotalPositions; + + // Invariant 2: TotalPositions == N (number of SavePositionNodes) + bool totalCorrect = capturedResult.TotalPositions == testCase.PositionCount; + + // Invariant 3: Status == "Completed" (not cancelled) + bool statusCorrect = capturedResult.Status == "Completed"; + + // Invariant 4: Each position's Status matches whether it succeeded or failed + bool positionsCorrect = capturedResult.Positions.Count == testCase.PositionCount; + if (positionsCorrect) + { + int succeededCount = capturedResult.Positions.Count(p => p.Status == "Success"); + int failedCount = capturedResult.Positions.Count(p => p.Status == "Failed"); + positionsCorrect = succeededCount == capturedResult.SucceededPositions + && failedCount == capturedResult.FailedPositions; + } + + return sumCorrect && totalCorrect && statusCorrect && positionsCorrect; + }); + } + + /// + /// **Validates: Requirements 4.1, 4.2** + /// + /// A position is marked "Success" if and only if its image capture completed + /// AND its inspection pipeline (if present) executed without error. + /// A position with image acquisition failure must be "Failed". + /// + [Property(MaxTest = 100)] + public Property Summary_PositionStatusReflectsActualOutcome() + { + var gen = + from posCount in Gen.Choose(1, 6) + from imageFailures in Gen.ListOf(posCount, Gen.Elements(true, false)) + from pipelineFailures in Gen.ListOf(posCount, Gen.Elements(true, false)) + from hasInspections in Gen.ListOf(posCount, ArbMap.Default.GeneratorFor()) + select new + { + PositionCount = posCount, + ImageFailures = imageFailures.ToArray(), + PipelineFailures = pipelineFailures.ToArray(), + HasInspections = hasInspections.ToArray() + }; + + return Prop.ForAll( + gen.ToArbitrary(), + testCase => + { + var nodes = BuildNodes(testCase.PositionCount, testCase.HasInspections); + var program = new CncProgram( + Guid.NewGuid(), + "TestProgram", + DateTime.UtcNow, + DateTime.UtcNow, + nodes.AsReadOnly()); + + var effectivePipelineFailures = new bool[testCase.PositionCount]; + for (int i = 0; i < testCase.PositionCount; i++) + { + effectivePipelineFailures[i] = testCase.HasInspections[i] && testCase.PipelineFailures[i]; + } + + var (service, mockImagePersistence) = CreateServiceWithFailurePatterns( + testCase.ImageFailures, + effectivePipelineFailures); + + BatchCaptureResult capturedResult = null; + mockImagePersistence + .Setup(s => s.WriteSummaryAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback((result, _, _) => + { + capturedResult = result; + }) + .ReturnsAsync(true); + + service.ExecuteAsync(program, null, CancellationToken.None) + .GetAwaiter().GetResult(); + + if (capturedResult == null || capturedResult.Positions.Count != testCase.PositionCount) + return false; + + // Verify each position's status matches expected outcome + for (int i = 0; i < testCase.PositionCount; i++) + { + var posResult = capturedResult.Positions[i]; + bool imageAcquired = !testCase.ImageFailures[i]; + bool pipelineFailed = effectivePipelineFailures[i]; + + // A position is "Success" iff image capture completed AND pipeline (if present) succeeded + bool expectedSuccess = imageAcquired && !pipelineFailed; + string expectedStatus = expectedSuccess ? "Success" : "Failed"; + + if (posResult.Status != expectedStatus) + return false; + } + + return true; + }); + } + } +} diff --git a/XplorePlane.Tests/ViewModels/CncNodeViewModelTests.cs b/XplorePlane.Tests/ViewModels/CncNodeViewModelTests.cs index 18d4368..c45e47d 100644 --- a/XplorePlane.Tests/ViewModels/CncNodeViewModelTests.cs +++ b/XplorePlane.Tests/ViewModels/CncNodeViewModelTests.cs @@ -2,6 +2,8 @@ // Validates: Requirements 6.1, 6.2 using System; +using System.Collections.Generic; +using System.ComponentModel; using FsCheck; using FsCheck.Fluent; using FsCheck.Xunit; @@ -65,5 +67,65 @@ namespace XplorePlane.Tests.ViewModels return runningOk && succeededOk && failedOk && idleOk; }); } + + #region ManualImagePath Binding Tests - Validates: Requirements 2.4 + + [Fact] + public void BrowseImageCommand_IsNotNull_AndCanExecute() + { + // Arrange + var node = new SavePositionNode(Guid.NewGuid(), 0, "Pos_0", MotionState.Default); + var vm = new CncNodeViewModel(node, (_, __) => { }); + + // Act & Assert + Assert.NotNull(vm.BrowseImageCommand); + Assert.True(vm.BrowseImageCommand.CanExecute(null)); + } + + [Fact] + public void ManualImagePath_Set_RaisesPropertyChangedNotification() + { + // Arrange + var node = new SavePositionNode(Guid.NewGuid(), 0, "Pos_0", MotionState.Default); + var vm = new CncNodeViewModel(node, (_, __) => { }); + var changedProperties = new List(); + ((INotifyPropertyChanged)vm).PropertyChanged += (_, e) => changedProperties.Add(e.PropertyName); + + // Act + vm.ManualImagePath = @"C:\Images\test.bmp"; + + // Assert + Assert.Contains(nameof(vm.ManualImagePath), changedProperties); + } + + [Fact] + public void ManualImagePath_Getter_ReturnsValueFromUnderlyingModel() + { + // Arrange + var expectedPath = @"D:\TestImages\sample.png"; + var node = new SavePositionNode(Guid.NewGuid(), 0, "Pos_0", MotionState.Default, ManualImagePath: expectedPath); + var vm = new CncNodeViewModel(node, (_, __) => { }); + + // Act & Assert + Assert.Equal(expectedPath, vm.ManualImagePath); + } + + [Fact] + public void ManualImagePath_Set_UpdatesUnderlyingModel() + { + // Arrange + var node = new SavePositionNode(Guid.NewGuid(), 0, "Pos_0", MotionState.Default); + var vm = new CncNodeViewModel(node, (_, __) => { }); + var newPath = @"C:\Images\updated.tiff"; + + // Act + vm.ManualImagePath = newPath; + + // Assert + var updatedNode = Assert.IsType(vm.Model); + Assert.Equal(newPath, updatedNode.ManualImagePath); + } + + #endregion } } diff --git a/XplorePlane/Helpers/FileNameSanitizer.cs b/XplorePlane/Helpers/FileNameSanitizer.cs new file mode 100644 index 0000000..d6d1da3 --- /dev/null +++ b/XplorePlane/Helpers/FileNameSanitizer.cs @@ -0,0 +1,26 @@ +namespace XplorePlane.Helpers +{ + /// + /// 文件名清理工具,将文件系统非法字符替换为下划线 + /// + public static class FileNameSanitizer + { + private static readonly char[] InvalidChars = + new[] { '\\', '/', ':', '*', '?', '"', '<', '>', '|' }; + + /// + /// 将文件名中的非法字符替换为下划线。 + /// 空字符串或 null 返回 "_"。 + /// + /// 待清理的文件名 + /// 清理后的安全文件名 + public static string Sanitize(string fileName) + { + if (string.IsNullOrEmpty(fileName)) return "_"; + var result = fileName; + foreach (var c in InvalidChars) + result = result.Replace(c, '_'); + return result; + } + } +} diff --git a/XplorePlane/Helpers/ManualImageValidator.cs b/XplorePlane/Helpers/ManualImageValidator.cs new file mode 100644 index 0000000..d6268ab --- /dev/null +++ b/XplorePlane/Helpers/ManualImageValidator.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace XplorePlane.Helpers +{ + /// + /// 手动图像路径验证结果枚举 + /// + public enum ManualImageValidationResult + { + Valid, + Empty, + PathTooLong, + FileNotFound, + UnsupportedFormat + } + + /// + /// 手动图像路径验证工具,检查路径有效性和文件格式支持 + /// + public static class ManualImageValidator + { + private static readonly HashSet SupportedExtensions = + new(StringComparer.OrdinalIgnoreCase) { ".bmp", ".png", ".tiff", ".tif" }; + + /// + /// 验证手动图像路径是否有效。 + /// + /// 待验证的文件路径 + /// 验证结果分类 + public static ManualImageValidationResult Validate(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return ManualImageValidationResult.Empty; + + if (path.Length > 260) + return ManualImageValidationResult.PathTooLong; + + if (!File.Exists(path)) + return ManualImageValidationResult.FileNotFound; + + var ext = Path.GetExtension(path); + if (!SupportedExtensions.Contains(ext)) + return ManualImageValidationResult.UnsupportedFormat; + + return ManualImageValidationResult.Valid; + } + } +} diff --git a/XplorePlane/Models/CncModels.cs b/XplorePlane/Models/CncModels.cs index 702c5a8..e3b17a5 100644 --- a/XplorePlane/Models/CncModels.cs +++ b/XplorePlane/Models/CncModels.cs @@ -86,7 +86,8 @@ namespace XplorePlane.Models int Index, string Name, MotionState MotionState, - bool SaveImage = false) : CncNode(Id, Index, CncNodeType.SavePosition, Name); + bool SaveImage = false, + string ManualImagePath = "") : CncNode(Id, Index, CncNodeType.SavePosition, Name); /// 检测模块节点 | Inspection module node public record InspectionModuleNode( diff --git a/XplorePlane/Models/ImageCaptureModels.cs b/XplorePlane/Models/ImageCaptureModels.cs new file mode 100644 index 0000000..f48ec46 --- /dev/null +++ b/XplorePlane/Models/ImageCaptureModels.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; + +namespace XplorePlane.Models +{ + // ── 图像采集结果模型 | Image Capture Result Models ──────────────── + + /// + /// 图像保存操作的结果 | Result of an image save operation + /// + public record ImageSaveResult( + bool Success, + string FilePath, + long FileSizeBytes, + string ErrorMessage = null); + + /// + /// 多位置批量采集运行摘要,序列化为 summary.json + /// | Batch capture run summary, serialized to summary.json + /// + public class BatchCaptureResult + { + public string ProgramName { get; set; } = string.Empty; + public string StartTime { get; set; } = string.Empty; // ISO 8601 + public double DurationSeconds { get; set; } + public int TotalPositions { get; set; } + public int SucceededPositions { get; set; } + public int FailedPositions { get; set; } + public int SavedImageCount { get; set; } + public string Status { get; set; } = "Completed"; // Completed / Cancelled + public int? CompletedBeforeCancel { get; set; } + public int? NotExecutedAfterCancel { get; set; } + public List Positions { get; set; } = new(); + } + + /// + /// 单个位置的执行结果 | Execution result for a single position + /// + public class PositionResult + { + public string NodeName { get; set; } = string.Empty; + public int NodeIndex { get; set; } + public string Status { get; set; } = "Success"; // Success / Failed + public string ErrorMessage { get; set; } + public string ImagePath { get; set; } + } +} diff --git a/XplorePlane/Services/Cnc/CncExecutionService.cs b/XplorePlane/Services/Cnc/CncExecutionService.cs index ee0ae59..f27912d 100644 --- a/XplorePlane/Services/Cnc/CncExecutionService.cs +++ b/XplorePlane/Services/Cnc/CncExecutionService.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.Json; @@ -10,6 +11,7 @@ using Prism.Events; using XP.Common.Converters; using XP.Common.Logging.Interfaces; using XplorePlane.Events; +using XplorePlane.Helpers; using XplorePlane.Models; using XplorePlane.Services.AppState; using XplorePlane.Services.InspectionResults; @@ -30,6 +32,7 @@ namespace XplorePlane.Services.Cnc private readonly IPipelineExecutionService _pipelineExecutionService; private readonly IImageProcessingService _imageProcessingService; private readonly IEventAggregator _eventAggregator; + private readonly IImagePersistenceService _imagePersistenceService; // Task 4.2: volatile field so reads/writes are not reordered across threads private volatile CancellationTokenSource _executionCts; @@ -41,7 +44,8 @@ namespace XplorePlane.Services.Cnc IAppStateService appStateService, IPipelineExecutionService pipelineExecutionService, IImageProcessingService imageProcessingService, - IEventAggregator eventAggregator) + IEventAggregator eventAggregator, + IImagePersistenceService imagePersistenceService) { _store = store ?? throw new ArgumentNullException(nameof(store)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); @@ -50,6 +54,7 @@ namespace XplorePlane.Services.Cnc _pipelineExecutionService = pipelineExecutionService; _imageProcessingService = imageProcessingService; _eventAggregator = eventAggregator ?? throw new ArgumentNullException(nameof(eventAggregator)); + _imagePersistenceService = imagePersistenceService ?? throw new ArgumentNullException(nameof(imagePersistenceService)); // Task 4.3: subscribe to DetectorDisconnectedEvent on a background thread _eventAggregator.GetEvent() @@ -121,6 +126,178 @@ namespace XplorePlane.Services.Cnc bool allSucceeded = true; BitmapSource lastResultImage = null; + // Task 5.1: Get all SavePositionNodes ordered by Index ascending for multi-position execution + var savePositionNodes = program.Nodes + .OfType() + .OrderBy(n => n.Index) + .ToList(); + int totalPositions = savePositionNodes.Count; + + // Task 5.1: Multi-position execution loop with progress reporting and cancellation + for (int positionIndex = 0; positionIndex < totalPositions; positionIndex++) + { + // Task 5.1: Check CancellationToken at the start of each iteration + if (linkedCts.Token.IsCancellationRequested) + { + cancelled = true; + _logger.ForModule().Info( + "Multi-position execution cancelled at position {0}/{1}", + positionIndex, totalPositions); + break; + } + + var sp = savePositionNodes[positionIndex]; + + // Task 5.1: Report progress (current 0-based index, total count) + progress?.Report(new CncNodeExecutionProgress( + sp.Id, + NodeExecutionState.Running, + ProgressPercent: 0, + PositionIndex: positionIndex, + TotalPositions: totalPositions)); + + _logger.ForModule().Info( + "Executing save-position node [{Index}] {Name} (position {Current}/{Total}) | " + + "StageX={StageX} StageY={StageY} SourceZ={SourceZ} DetectorZ={DetectorZ} " + + "DetectorSwing={DetectorSwing} FDD={FDD} FOD={FOD} Magnification={Magnification} " + + "StageRotation={StageRotation} FixtureRotation={FixtureRotation} SaveImage={SaveImage}", + sp.Index, sp.Name, positionIndex + 1, totalPositions, + sp.MotionState.StageX, sp.MotionState.StageY, + sp.MotionState.SourceZ, sp.MotionState.DetectorZ, + sp.MotionState.DetectorSwing, sp.MotionState.FDD, + sp.MotionState.FOD, sp.MotionState.Magnification, + sp.MotionState.StageRotation, sp.MotionState.FixtureRotation, + sp.SaveImage); + + bool nodeSucceeded = true; + + try + { + // Task 5.2: Manual image source loading logic + BitmapSource positionImage = null; + + if (!string.IsNullOrEmpty(sp.ManualImagePath)) + { + // ManualImagePath is set - validate and load from file + var validationResult = ManualImageValidator.Validate(sp.ManualImagePath); + + if (validationResult == ManualImageValidationResult.Valid) + { + // Load image from file + positionImage = LoadImageFromFile(sp.ManualImagePath); + if (positionImage != null) + { + currentSourceImage = positionImage; + _logger.ForModule().Info( + "Loaded manual image from '{0}' for node '{1}'", + sp.ManualImagePath, sp.Name); + } + else + { + _logger.ForModule().Warn( + "Failed to decode image file '{0}' for node '{1}'", + sp.ManualImagePath, sp.Name); + nodeSucceeded = false; + } + } + else + { + // Validation failed - show error dialog and abort current node + var errorMessage = validationResult switch + { + ManualImageValidationResult.PathTooLong => + $"图像路径过长(超过260字符):\n{sp.ManualImagePath}", + ManualImageValidationResult.FileNotFound => + $"图像文件不存在:\n{sp.ManualImagePath}", + ManualImageValidationResult.UnsupportedFormat => + $"不支持的图像格式(仅支持 BMP、PNG、TIFF):\n{sp.ManualImagePath}", + _ => $"图像路径无效:\n{sp.ManualImagePath}" + }; + + _logger.ForModule().Warn( + "Manual image validation failed for node '{0}': {1} - Path: '{2}'", + sp.Name, validationResult, sp.ManualImagePath); + + await Application.Current.Dispatcher.InvokeAsync(() => + MessageBox.Show(errorMessage, "手动图像加载失败", MessageBoxButton.OK, MessageBoxImage.Error)); + + nodeSucceeded = false; + } + } + else + { + // ManualImagePath is empty - use detector acquisition + var capturedImage = TryGetSourceImage(); + if (capturedImage != null) + { + positionImage = capturedImage; + currentSourceImage = capturedImage; + } + else + { + _logger.ForModule().Warn( + "Save-position node '{0}' requested image capture, but no current image was available.", + sp.Name); + } + } + + // Task 5.3: Image persistence - save image when SaveImage=true + if (sp.SaveImage && currentSourceImage != null) + { + var imageBytes = EncodeBitmapToBmp(currentSourceImage); + var saveResult = await _imagePersistenceService.SaveImageAsync( + imageBytes, sp.Name, program.Name, linkedCts.Token); + + if (saveResult.Success) + { + _logger.ForModule().Info( + "Image saved for node '{0}': Path={1}, Size={2} bytes", + sp.Name, saveResult.FilePath, saveResult.FileSizeBytes); + } + // Note: saveResult.Success == false will be handled by Task 5.4's error tolerance logic + } + + // Task 5.3: Check if the next node (by Index order) is an InspectionModuleNode + // and pass the acquired image as source to the pipeline execution + if (currentSourceImage != null) + { + var allNodesOrdered = program.Nodes.OrderBy(n => n.Index).ToList(); + int currentNodeOrderIndex = allNodesOrdered.FindIndex(n => n.Id == sp.Id); + if (currentNodeOrderIndex >= 0 && currentNodeOrderIndex + 1 < allNodesOrdered.Count) + { + var nextNode = allNodesOrdered[currentNodeOrderIndex + 1]; + if (nextNode is InspectionModuleNode inspectionNode) + { + _logger.ForModule().Info( + "Passing captured image from node '{0}' to inspection module '{1}'", + sp.Name, inspectionNode.Name); + + var resultImage = await ExecuteInspectionNodeAsync( + runId, inspectionNode, currentSourceImage, linkedCts.Token); + if (resultImage != null) + lastResultImage = resultImage; + } + } + } + + // TODO (Task 5.4): Implement error tolerance and failure-continue logic + } + catch (Exception ex) + { + _logger.ForModule().Error(ex, + "Unexpected error executing save-position node '{0}' at position {1}/{2}", + sp.Name, positionIndex + 1, totalPositions); + nodeSucceeded = false; + } + + var finalState = nodeSucceeded ? NodeExecutionState.Succeeded : NodeExecutionState.Failed; + progress?.Report(new CncNodeExecutionProgress(sp.Id, finalState)); + + if (!nodeSucceeded) + allSucceeded = false; + } + + // Process remaining non-SavePosition nodes in order foreach (var node in program.Nodes.OrderBy(n => n.Index)) { if (linkedCts.Token.IsCancellationRequested) @@ -129,6 +306,10 @@ namespace XplorePlane.Services.Cnc break; } + // Skip SavePositionNodes - already processed in multi-position loop above + if (node is SavePositionNode) + continue; + progress?.Report(new CncNodeExecutionProgress(node.Id, NodeExecutionState.Running, ProgressPercent: 0)); bool nodeSucceeded = true; @@ -151,36 +332,6 @@ namespace XplorePlane.Services.Cnc rp.IsRayOn, rp.Voltage, rp.Current); break; - case SavePositionNode sp: - _logger.ForModule().Info( - "Executing save-position node [{Index}] {Name} | " + - "StageX={StageX} StageY={StageY} SourceZ={SourceZ} DetectorZ={DetectorZ} " + - "DetectorSwing={DetectorSwing} FDD={FDD} FOD={FOD} Magnification={Magnification} " + - "StageRotation={StageRotation} FixtureRotation={FixtureRotation} SaveImage={SaveImage}", - sp.Index, sp.Name, - sp.MotionState.StageX, sp.MotionState.StageY, - sp.MotionState.SourceZ, sp.MotionState.DetectorZ, - sp.MotionState.DetectorSwing, sp.MotionState.FDD, - sp.MotionState.FOD, sp.MotionState.Magnification, - sp.MotionState.StageRotation, sp.MotionState.FixtureRotation, - sp.SaveImage); - - if (sp.SaveImage) - { - var capturedImage = TryGetSourceImage(); - if (capturedImage != null) - { - currentSourceImage = capturedImage; - } - else - { - _logger.ForModule().Warn( - "Save-position node '{0}' requested image capture, but no current image was available.", - sp.Name); - } - } - break; - case SaveNodeNode sn: _logger.ForModule().Info( "Executing save node [{Index}] {Name} | " + @@ -317,6 +468,27 @@ namespace XplorePlane.Services.Cnc return bitmap; } + /// + /// Loads a BitmapSource from a local file path. Returns null if loading fails. + /// + private static BitmapSource LoadImageFromFile(string filePath) + { + try + { + var bitmap = new BitmapImage(); + bitmap.BeginInit(); + bitmap.UriSource = new Uri(filePath, UriKind.Absolute); + bitmap.CacheOption = BitmapCacheOption.OnLoad; + bitmap.EndInit(); + bitmap.Freeze(); + return bitmap; + } + catch + { + return null; + } + } + private async Task ExecuteInspectionNodeAsync( Guid runId, InspectionModuleNode inspectionNode, diff --git a/XplorePlane/Services/Cnc/ICncExecutionService.cs b/XplorePlane/Services/Cnc/ICncExecutionService.cs index 602c655..e5de21b 100644 --- a/XplorePlane/Services/Cnc/ICncExecutionService.cs +++ b/XplorePlane/Services/Cnc/ICncExecutionService.cs @@ -18,10 +18,13 @@ namespace XplorePlane.Services.Cnc /// Progress report for a single CNC node execution. /// ResultImage is non-null when an InspectionModuleNode produces output. /// ProgressPercent is used by long-running nodes such as WaitDelayNode. + /// PositionIndex and TotalPositions are used for multi-position execution progress. /// public record CncNodeExecutionProgress( Guid NodeId, NodeExecutionState State, BitmapSource ResultImage = null, - double? ProgressPercent = null); + double? ProgressPercent = null, + int? PositionIndex = null, + int? TotalPositions = null); } diff --git a/XplorePlane/Services/Cnc/IImagePersistenceService.cs b/XplorePlane/Services/Cnc/IImagePersistenceService.cs new file mode 100644 index 0000000..5304490 --- /dev/null +++ b/XplorePlane/Services/Cnc/IImagePersistenceService.cs @@ -0,0 +1,49 @@ +using System.Threading; +using System.Threading.Tasks; +using XplorePlane.Models; + +namespace XplorePlane.Services.Cnc +{ + /// + /// 图像持久化服务接口,负责将采集图像保存到 Batch_Capture_Directory。 + /// | Image persistence service interface for saving captured images to the batch capture directory. + /// + public interface IImagePersistenceService + { + /// + /// 将图像保存到批量采集目录。 + /// | Save an image to the batch capture directory. + /// + /// BMP 编码的图像字节数组 + /// 节点名称(用于文件命名) + /// CNC 程序名称(用于目录结构) + /// 取消令牌 + /// 保存结果,包含成功状态、文件路径和大小 + Task SaveImageAsync( + byte[] imageData, + string nodeName, + string programName, + CancellationToken cancellationToken = default); + + /// + /// 获取当前批次的采集目录路径。 + /// | Get the batch capture directory path for the given program. + /// + /// CNC 程序名称 + /// 批量采集目录的完整路径 + string GetBatchCaptureDirectory(string programName); + + /// + /// 将运行摘要写入 summary.json。 + /// | Write the batch run summary to summary.json. + /// + /// 批量采集结果摘要 + /// CNC 程序名称 + /// 取消令牌 + /// 写入成功返回 true,失败返回 false + Task WriteSummaryAsync( + BatchCaptureResult summary, + string programName, + CancellationToken cancellationToken = default); + } +} diff --git a/XplorePlane/Services/Cnc/ImagePersistenceService.cs b/XplorePlane/Services/Cnc/ImagePersistenceService.cs new file mode 100644 index 0000000..13e49c6 --- /dev/null +++ b/XplorePlane/Services/Cnc/ImagePersistenceService.cs @@ -0,0 +1,125 @@ +using System; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using XP.Common.Logging.Interfaces; +using XplorePlane.Helpers; +using XplorePlane.Models; +using XplorePlane.Services.Storage; + +namespace XplorePlane.Services.Cnc +{ + /// + /// 图像持久化服务实现,负责将采集图像保存到 Batch_Capture_Directory。 + /// | Image persistence service implementation for saving captured images to the batch capture directory. + /// + public class ImagePersistenceService : IImagePersistenceService + { + private readonly IXpDataPathService _dataPathService; + private readonly ILoggerService _logger; + private static readonly TimeSpan WriteTimeout = TimeSpan.FromSeconds(10); + + public ImagePersistenceService( + IXpDataPathService dataPathService, + ILoggerService logger) + { + _dataPathService = dataPathService ?? throw new ArgumentNullException(nameof(dataPathService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public string GetBatchCaptureDirectory(string programName) + { + var dataPath = _dataPathService.DataPath; + var datePart = DateTime.Now.ToString("yyyy-MM-dd"); + return Path.Combine(dataPath, "CapturedImages", datePart, programName); + } + + /// + public async Task SaveImageAsync( + byte[] imageData, + string nodeName, + string programName, + CancellationToken cancellationToken = default) + { + try + { + var directory = GetBatchCaptureDirectory(programName); + Directory.CreateDirectory(directory); + + var sanitizedName = FileNameSanitizer.Sanitize(nodeName); + var timestamp = DateTime.Now.ToString("HHmmss_fff"); + var fileName = $"{sanitizedName}_{timestamp}.bmp"; + var filePath = Path.Combine(directory, fileName); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(WriteTimeout); + + await File.WriteAllBytesAsync(filePath, imageData, timeoutCts.Token); + + var fileSize = new FileInfo(filePath).Length; + + _logger.ForModule().Info( + "Image saved: {0} ({1} bytes)", filePath, fileSize); + + return new ImageSaveResult(true, filePath, fileSize); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + // Timeout occurred (not external cancellation) + var errorMsg = $"Image save timed out after {WriteTimeout.TotalSeconds}s for node '{nodeName}'"; + _logger.ForModule().Error(null, errorMsg); + return new ImageSaveResult(false, null, 0, errorMsg); + } + catch (OperationCanceledException) + { + // External cancellation + var errorMsg = $"Image save cancelled for node '{nodeName}'"; + _logger.ForModule().Warn(errorMsg); + return new ImageSaveResult(false, null, 0, errorMsg); + } + catch (Exception ex) + { + var errorMsg = $"Failed to save image for node '{nodeName}': {ex.Message}"; + _logger.ForModule().Error(ex, + "Failed to save image for node '{0}'", nodeName); + return new ImageSaveResult(false, null, 0, errorMsg); + } + } + + /// + public async Task WriteSummaryAsync( + BatchCaptureResult summary, + string programName, + CancellationToken cancellationToken = default) + { + try + { + var directory = GetBatchCaptureDirectory(programName); + Directory.CreateDirectory(directory); + + var filePath = Path.Combine(directory, "summary.json"); + + var options = new JsonSerializerOptions + { + WriteIndented = true + }; + + var json = JsonSerializer.Serialize(summary, options); + await File.WriteAllTextAsync(filePath, json, cancellationToken); + + _logger.ForModule().Info( + "Summary written to: {0}", filePath); + + return true; + } + catch (Exception ex) + { + _logger.ForModule().Error(ex, + "Failed to write summary for program '{0}'", programName); + return false; + } + } + } +} diff --git a/XplorePlane/ViewModels/Cnc/CncNodeViewModel.cs b/XplorePlane/ViewModels/Cnc/CncNodeViewModel.cs index 9cc4760..7ee7d2d 100644 --- a/XplorePlane/ViewModels/Cnc/CncNodeViewModel.cs +++ b/XplorePlane/ViewModels/Cnc/CncNodeViewModel.cs @@ -1,6 +1,9 @@ +using Microsoft.Win32; +using Prism.Commands; using Prism.Mvvm; using System; using System.Collections.ObjectModel; +using System.Windows.Input; using System.Windows.Media.Imaging; using XplorePlane.Models; @@ -361,6 +364,35 @@ namespace XplorePlane.ViewModels.Cnc } } + public string ManualImagePath + { + get => _model is SavePositionNode sp ? sp.ManualImagePath : string.Empty; + set + { + if (_model is SavePositionNode sp) + { + UpdateModel(sp with { ManualImagePath = value ?? string.Empty }); + } + } + } + + public ICommand BrowseImageCommand => _browseImageCommand ??= new DelegateCommand(ExecuteBrowseImage); + private DelegateCommand _browseImageCommand; + + private void ExecuteBrowseImage() + { + var dialog = new OpenFileDialog + { + Title = "选择图像文件", + Filter = "Image Files|*.bmp;*.png;*.tiff;*.tif|BMP Files|*.bmp|PNG Files|*.png|TIFF Files|*.tiff;*.tif" + }; + + if (dialog.ShowDialog() == true) + { + ManualImagePath = dialog.FileName; + } + } + public string PipelineName { get => _model is InspectionModuleNode im ? im.Pipeline?.Name ?? string.Empty : string.Empty; @@ -649,6 +681,7 @@ namespace XplorePlane.ViewModels.Cnc RaisePropertyChanged(nameof(Resolution)); RaisePropertyChanged(nameof(ImageFileName)); RaisePropertyChanged(nameof(SaveImage)); + RaisePropertyChanged(nameof(ManualImagePath)); RaisePropertyChanged(nameof(Pipeline)); RaisePropertyChanged(nameof(PipelineName)); RaisePropertyChanged(nameof(MarkerType));