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