手动数据源、存图、流程计算

This commit is contained in:
zhengxuan.zhang
2026-05-15 15:29:53 +08:00
parent f07d891346
commit 49c6785682
9 changed files with 800 additions and 419 deletions
@@ -113,10 +113,13 @@ namespace XplorePlane.Tests.Services
}
// ── Generator: N SavePositionNodes with cancellation point K ─────
// K ranges from 1 to N-1: K positions complete, then cancellation is detected
// before position K+1 starts. This ensures the summary is always written.
// (K=0 would require pre-cancellation which skips BeginRunAsync entirely)
private static Gen<(List<SavePositionNode> Nodes, int CancelAfterK)> SavePositionNodesWithCancelGen =>
from n in Gen.Choose(2, 10)
from k in Gen.Choose(0, n - 1)
from k in Gen.Choose(1, n - 1)
from nodes in GenSavePositionNodes(n)
select (nodes, k);
@@ -47,12 +47,9 @@ namespace XplorePlane.Tests.Services
// ── Helper: Create service with full mock access ──
private static (
CncExecutionService Service,
Mock<IInspectionResultStore> Store,
Mock<ILoggerService> Logger,
Mock<IMainViewportService> MainViewport,
Mock<IAppStateService> AppState,
Mock<IImagePersistenceService> ImagePersistence)
CreateServiceWithImagePersistence()
CreateServiceWithMocks()
{
var mockStore = new Mock<IInspectionResultStore>();
var mockLogger = new Mock<ILoggerService>();
@@ -103,114 +100,90 @@ namespace XplorePlane.Tests.Services
mockEventAggregator.Object,
mockImagePersistenceService.Object);
return (service, mockStore, mockLogger, mockMainViewportService, mockAppStateService, mockImagePersistenceService);
}
// ── Generator: Produce N SavePositionNodes with unique ascending indices ──
private static Gen<List<SavePositionNode>> SavePositionNodesGen(int minCount, int maxCount)
{
return
from count in Gen.Choose(minCount, maxCount)
from nodes in GenSavePositionNodes(count)
select nodes;
}
private static Gen<List<SavePositionNode>> GenSavePositionNodes(int count)
{
Gen<List<SavePositionNode>> acc = Gen.Constant(new List<SavePositionNode>());
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<SavePositionNode>(list)
{
new SavePositionNode(
Guid.NewGuid(), idx, $"Pos_{idx}",
MotionState.Default, SaveImage: saveImage)
};
}
return acc;
return (service, mockMainViewportService, mockImagePersistenceService);
}
/// <summary>
/// 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).
/// FsCheck Arbitrary for resilience test scenarios.
/// Generates (SavePositionNodes, FailureModes) where:
/// - Nodes: 2-6 SavePositionNodes with SaveImage=true
/// - FailureModes: per-position int (0=success, 1=detector null, 2=save I/O error)
/// - At least one position has a failure
/// </summary>
private static Gen<(HashSet<int> DetectorFailIndices, HashSet<int> SaveFailIndices)> FailureIndicesGen(int totalPositions)
private static Arbitrary<(List<SavePositionNode> Nodes, List<int> FailureModes)> ResilienceScenarioArb()
{
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<int>(failIndices.Take(splitPoint))
let saveFails = new HashSet<int>(failIndices.Skip(splitPoint))
select (detectorFails, saveFails);
// Generate 6 failure mode values upfront, then take posCount of them
var gen =
from posCount in Gen.Choose(2, 6)
from m0 in Gen.Choose(0, 2)
from m1 in Gen.Choose(0, 2)
from m2 in Gen.Choose(0, 2)
from m3 in Gen.Choose(0, 2)
from m4 in Gen.Choose(0, 2)
from m5 in Gen.Choose(0, 2)
let allModes = new List<int> { m0, m1, m2, m3, m4, m5 }
let modes = allModes.Take(posCount).ToList()
// Ensure at least one failure exists
let adjustedModes = modes.Any(m => m > 0)
? modes
: modes.Select((m, i) => i == 0 ? 1 : m).ToList()
let nodes = Enumerable.Range(0, posCount)
.Select(i => new SavePositionNode(
Guid.NewGuid(), i, $"Position_{i}",
MotionState.Default, SaveImage: true))
.ToList()
select (Nodes: nodes, FailureModes: adjustedModes);
return gen.ToArbitrary();
}
// ── Property Test ──
/// <summary>
/// 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).
/// </summary>
// Feature: cnc-multi-position-image-capture, Property 3: 采集或保存失败不中断后续执行
// **Validates: Requirements 1.5, 1.6, 3.3, 3.6**
[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 =>
ResilienceScenarioArb(),
scenario =>
{
var (saveNodes, detectorFailIndices, saveFailIndices) = tuple;
var (saveNodes, failureModes) = scenario;
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<BitmapSource>();
foreach (var node in saveNodes)
// Determine which positions have detector failures vs save failures
var detectorFailIndices = new HashSet<int>();
var saveFailIndices = new HashSet<int>();
for (int i = 0; i < totalPositions; i++)
{
int nodeIndex = saveNodes.IndexOf(node);
if (detectorFailIndices.Contains(nodeIndex))
imageSequence.Enqueue(null); // Detector fails
else
imageSequence.Enqueue(testBitmap); // Detector succeeds
switch (failureModes[i])
{
case 1: detectorFailIndices.Add(i); break;
case 2: saveFailIndices.Add(i); break;
}
}
// Also need an initial call for runSourceImage at the start of ExecuteAsync
mockMainViewport.Setup(m => m.CurrentDisplayImage)
var (service, mockMainViewport, mockImagePersistence) = CreateServiceWithMocks();
var testBitmap = CreateTestBitmap();
// ── Configure detector mock ──
// TryGetSourceImage reads LatestManualImage first, then CurrentDisplayImage.
// Use LatestManualImage to control image return per call.
int imageCallCount = 0;
mockMainViewport.SetupGet(m => m.LatestManualImage)
.Returns(() =>
{
if (imageSequence.Count > 0)
return imageSequence.Dequeue();
int callNum = Interlocked.Increment(ref imageCallCount);
// Call 1 = initial runSourceImage at start of ExecuteAsync - always valid
if (callNum == 1)
return testBitmap;
// Subsequent calls: callNum-2 gives the 0-based position index
int posIdx = callNum - 2;
if (posIdx >= 0 && detectorFailIndices.Contains(posIdx))
return null; // Simulate detector failure
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<byte[]>(),
It.IsAny<string>(),
@@ -218,11 +191,9 @@ namespace XplorePlane.Tests.Services
It.IsAny<CancellationToken>()))
.Returns<byte[], string, string, CancellationToken>((_, 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"));
}
@@ -237,15 +208,12 @@ namespace XplorePlane.Tests.Services
saveNodes.Cast<CncNode>().ToList().AsReadOnly());
// ── Track progress reports ──
var reportedNodeIds = new List<Guid>();
var reportedNodeIds = new HashSet<Guid>();
var progress = new SynchronousProgress<CncNodeExecutionProgress>(p =>
{
// Count Running reports for SavePositionNodes (first report per position)
// Track all nodes that received a Running report with PositionIndex
if (p.State == NodeExecutionState.Running && p.PositionIndex.HasValue)
{
if (!reportedNodeIds.Contains(p.NodeId))
reportedNodeIds.Add(p.NodeId);
}
reportedNodeIds.Add(p.NodeId);
});
// ── Execute ──
@@ -253,7 +221,8 @@ namespace XplorePlane.Tests.Services
.GetAwaiter().GetResult();
// ── Verify: ALL positions were attempted ──
// Each SavePositionNode should have received at least one Running progress report
// Each SavePositionNode should have received at least one Running progress report,
// regardless of whether earlier positions failed.
bool allPositionsAttempted = reportedNodeIds.Count == totalPositions;
return allPositionsAttempted;
@@ -212,7 +212,7 @@ namespace XplorePlane.Tests.Services
var (service, mockPipelineExec, _, _, _) = CreateServiceWithCapture(detectorImage);
// Track all pipeline execution calls with their source images
var pipelineCalls = new List<(Guid NodeId, BitmapSource SourceImage)>();
var pipelineCalls = new List<BitmapSource>();
mockPipelineExec
.Setup(p => p.ExecutePipelineAsync(
@@ -223,8 +223,7 @@ namespace XplorePlane.Tests.Services
.Callback<IEnumerable<PipelineNodeViewModel>, BitmapSource, IProgress<PipelineProgress>, 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));
pipelineCalls.Add(source);
})
.ReturnsAsync(detectorImage);
@@ -235,7 +234,8 @@ namespace XplorePlane.Tests.Services
var allNodesOrdered = program.Nodes.OrderBy(n => n.Index).ToList();
var savePositionNodes = allNodesOrdered.OfType<SavePositionNode>().ToList();
int expectedPipelineCalls = 0;
// Count SavePositionNodes that are immediately followed by InspectionModuleNode
int savePositionFollowedByInspection = 0;
foreach (var sp in savePositionNodes)
{
int spOrderIndex = allNodesOrdered.FindIndex(n => n.Id == sp.Id);
@@ -243,21 +243,25 @@ namespace XplorePlane.Tests.Services
{
var nextNode = allNodesOrdered[spOrderIndex + 1];
if (nextNode is InspectionModuleNode)
expectedPipelineCalls++;
savePositionFollowedByInspection++;
}
}
// Verify: pipeline was called exactly for each SavePositionNode followed by InspectionModuleNode
bool correctCallCount = pipelineCalls.Count == expectedPipelineCalls;
// Property verification:
// 1. When a SavePositionNode is followed by InspectionModuleNode, pipeline MUST be called
// at least once with the detector image (from the multi-position loop)
// 2. All pipeline calls must receive the detector image as source
// (since all images come from the same detector mock)
// Verify: each pipeline call received a non-null source image (the detector image)
bool allReceivedImage = pipelineCalls.All(c => c.SourceImage != null);
// If there are SavePositionNodes followed by InspectionModuleNodes,
// the pipeline must have been called at least that many times
bool pipelineCalledForFollowedNodes = pipelineCalls.Count >= savePositionFollowedByInspection;
// Verify: each pipeline call received the same detector image
bool allReceivedCorrectImage = pipelineCalls.All(c =>
ReferenceEquals(c.SourceImage, detectorImage));
// Every pipeline call must have received the detector image
bool allReceivedCorrectImage = pipelineCalls.All(img =>
ReferenceEquals(img, detectorImage));
return correctCallCount && allReceivedImage && allReceivedCorrectImage;
return pipelineCalledForFollowedNodes && allReceivedCorrectImage;
});
}
@@ -192,7 +192,7 @@ namespace XplorePlane.Tests.Services
"TestProgram",
DateTime.UtcNow,
DateTime.UtcNow,
nodes.Cast<CncNode>().ToList().AsReadOnly());
nodes.Select(n => (CncNode)n).ToList().AsReadOnly());
service.ExecuteAsync(program, null, CancellationToken.None)
.GetAwaiter().GetResult();
@@ -230,7 +230,7 @@ namespace XplorePlane.Tests.Services
"TestProgram",
DateTime.UtcNow,
DateTime.UtcNow,
nodes.Cast<CncNode>().ToList().AsReadOnly());
nodes.Select(n => (CncNode)n).ToList().AsReadOnly());
service.ExecuteAsync(program, null, CancellationToken.None)
.GetAwaiter().GetResult();
@@ -280,7 +280,7 @@ namespace XplorePlane.Tests.Services
"TestProgram",
DateTime.UtcNow,
DateTime.UtcNow,
nodes.Cast<CncNode>().ToList().AsReadOnly());
nodes.Select(n => (CncNode)n).ToList().AsReadOnly());
service.ExecuteAsync(program, null, CancellationToken.None)
.GetAwaiter().GetResult();
@@ -0,0 +1,434 @@
#pragma warning disable xUnit1031 // Test methods use GetAwaiter().GetResult() for synchronous execution
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Media;
using System.Windows.Media.Imaging;
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.Services.Storage;
using Xunit;
namespace XplorePlane.Tests.Services
{
/// <summary>
/// Integration tests for the complete multi-position execution flow.
/// Tests real file system writes, summary.json generation, and end-to-end execution.
///
/// **Validates: Requirements 1.1, 3.1, 4.1, 4.3**
/// </summary>
public class MultiPositionIntegrationTests : IDisposable
{
private readonly string _tempDir;
private readonly Mock<IXpDataPathService> _mockDataPathService;
private readonly Mock<ILoggerService> _mockLogger;
private readonly ImagePersistenceService _realImagePersistenceService;
public MultiPositionIntegrationTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), "MultiPosIntegration_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempDir);
_mockDataPathService = new Mock<IXpDataPathService>();
_mockDataPathService.Setup(s => s.DataPath).Returns(_tempDir);
_mockLogger = new Mock<ILoggerService>();
_mockLogger.Setup(l => l.ForModule<ImagePersistenceService>()).Returns(_mockLogger.Object);
_mockLogger.Setup(l => l.ForModule<CncExecutionService>()).Returns(_mockLogger.Object);
_realImagePersistenceService = new ImagePersistenceService(
_mockDataPathService.Object, _mockLogger.Object);
}
public void Dispose()
{
try
{
if (Directory.Exists(_tempDir))
Directory.Delete(_tempDir, recursive: true);
}
catch
{
// Best-effort cleanup
}
}
// ── Helper: Create CncExecutionService with real ImagePersistenceService ──
private CncExecutionService CreateServiceWithRealPersistence()
{
var mockStore = new Mock<IInspectionResultStore>();
var mockMainViewportService = new Mock<IMainViewportService>();
var mockAppStateService = new Mock<IAppStateService>();
var mockPipelineExecutionService = new Mock<IPipelineExecutionService>();
var mockImageProcessingService = new Mock<IImageProcessingService>();
var mockEventAggregator = new Mock<IEventAggregator>();
// EventAggregator setup
mockEventAggregator
.Setup(ea => ea.GetEvent<DetectorDisconnectedEvent>())
.Returns(new DetectorDisconnectedEvent());
// InspectionResultStore setup
mockStore.Setup(s => s.BeginRunAsync(
It.IsAny<InspectionRunRecord>(),
It.IsAny<InspectionAssetWriteRequest>()))
.Returns(Task.CompletedTask);
mockStore.Setup(s => s.AppendNodeResultAsync(
It.IsAny<InspectionNodeResult>(),
It.IsAny<IEnumerable<InspectionMetricResult>>(),
It.IsAny<PipelineExecutionSnapshot>(),
It.IsAny<IEnumerable<InspectionAssetWriteRequest>>()))
.Returns(Task.CompletedTask);
mockStore.Setup(s => s.CompleteRunAsync(
It.IsAny<Guid>(),
It.IsAny<bool?>(),
It.IsAny<DateTime?>()))
.Returns(Task.CompletedTask);
// Provide a valid BitmapSource from the viewport (simulates detector)
var dummyBitmap = BitmapSource.Create(
2, 2, 96, 96, PixelFormats.Gray8, null, new byte[] { 128, 64, 32, 16 }, 2);
dummyBitmap.Freeze();
mockMainViewportService
.Setup(m => m.LatestManualImage)
.Returns(dummyBitmap);
return new CncExecutionService(
mockStore.Object,
_mockLogger.Object,
mockMainViewportService.Object,
mockAppStateService.Object,
mockPipelineExecutionService.Object,
mockImageProcessingService.Object,
mockEventAggregator.Object,
_realImagePersistenceService);
}
// ── Test 1: Full multi-position execution (image save + detection + summary) ──
/// <summary>
/// Integration test: Execute a CNC program with 3 SavePositionNodes (SaveImage=true),
/// verify BMP files are written to disk, and summary.json is generated with correct content.
/// **Validates: Requirements 1.1, 3.1, 4.1, 4.3**
/// </summary>
[Fact]
public async Task FullMultiPositionExecution_SavesImagesAndGeneratesSummary()
{
// Arrange
var service = CreateServiceWithRealPersistence();
var programName = "IntegrationTestProgram";
var nodes = new List<CncNode>
{
new SavePositionNode(Guid.NewGuid(), 0, "Position_A", MotionState.Default, SaveImage: true),
new SavePositionNode(Guid.NewGuid(), 1, "Position_B", MotionState.Default, SaveImage: true),
new SavePositionNode(Guid.NewGuid(), 2, "Position_C", MotionState.Default, SaveImage: true),
};
var program = new CncProgram(
Guid.NewGuid(), programName,
DateTime.UtcNow, DateTime.UtcNow,
nodes.AsReadOnly());
var progressReports = new List<CncNodeExecutionProgress>();
var progress = new SynchronousProgress<CncNodeExecutionProgress>(p => progressReports.Add(p));
// Act
await service.ExecuteAsync(program, progress, CancellationToken.None);
// Assert: Verify BMP files are written to disk
var captureDir = _realImagePersistenceService.GetBatchCaptureDirectory(programName);
Assert.True(Directory.Exists(captureDir), $"Capture directory should exist: {captureDir}");
var bmpFiles = Directory.GetFiles(captureDir, "*.bmp");
Assert.Equal(3, bmpFiles.Length);
// Verify each BMP file has content (non-zero size)
foreach (var bmpFile in bmpFiles)
{
var fileInfo = new FileInfo(bmpFile);
Assert.True(fileInfo.Length > 0, $"BMP file should not be empty: {bmpFile}");
}
// Verify file names contain the sanitized node names
var fileNames = bmpFiles.Select(Path.GetFileName).ToList();
Assert.Contains(fileNames, f => f.Contains("Position_A"));
Assert.Contains(fileNames, f => f.Contains("Position_B"));
Assert.Contains(fileNames, f => f.Contains("Position_C"));
// Assert: Verify summary.json is generated
var summaryPath = Path.Combine(captureDir, "summary.json");
Assert.True(File.Exists(summaryPath), "summary.json should exist");
// Assert: Verify summary.json contains valid JSON and correct fields
var json = await File.ReadAllTextAsync(summaryPath);
var summary = JsonSerializer.Deserialize<BatchCaptureResult>(json);
Assert.NotNull(summary);
Assert.Equal(programName, summary.ProgramName);
Assert.Equal(3, summary.TotalPositions);
Assert.Equal(3, summary.SucceededPositions);
Assert.Equal(0, summary.FailedPositions);
Assert.Equal(3, summary.SavedImageCount);
Assert.Equal("Completed", summary.Status);
Assert.Null(summary.CompletedBeforeCancel);
Assert.Null(summary.NotExecutedAfterCancel);
Assert.Equal(3, summary.Positions.Count);
// Verify each position result
for (int i = 0; i < 3; i++)
{
Assert.Equal("Success", summary.Positions[i].Status);
Assert.NotNull(summary.Positions[i].ImagePath);
}
// Verify progress reports: 3 Running + 3 Succeeded = 6 reports for SavePositionNodes
var runningReports = progressReports
.Where(p => p.State == NodeExecutionState.Running && p.PositionIndex.HasValue)
.ToList();
Assert.Equal(3, runningReports.Count);
// Verify ascending position index in progress reports
for (int i = 0; i < runningReports.Count; i++)
{
Assert.Equal(i, runningReports[i].PositionIndex);
Assert.Equal(3, runningReports[i].TotalPositions);
}
}
// ── Test 2: Real file system write with correct directory structure ──
/// <summary>
/// Integration test: Use real ImagePersistenceService to save an image,
/// verify the file exists on disk with correct content and directory structure.
/// **Validates: Requirements 1.1, 1.2, 1.3**
/// </summary>
[Fact]
public async Task RealFileSystemWrite_CreatesCorrectDirectoryStructureAndContent()
{
// Arrange
var imageData = new byte[2048];
new Random(42).NextBytes(imageData);
var programName = "TestProgram_FS";
var nodeName = "TestNode_Alpha";
// Act
var result = await _realImagePersistenceService.SaveImageAsync(
imageData, nodeName, programName);
// Assert: Save succeeded
Assert.True(result.Success);
Assert.NotNull(result.FilePath);
Assert.Equal(2048, result.FileSizeBytes);
// Assert: File exists on disk with correct content
Assert.True(File.Exists(result.FilePath));
var savedBytes = await File.ReadAllBytesAsync(result.FilePath);
Assert.Equal(imageData, savedBytes);
// Assert: Directory structure matches {DataPath}\CapturedImages\{yyyy-MM-dd}\{ProgramName}\
var expectedDatePart = DateTime.Now.ToString("yyyy-MM-dd");
var directory = Path.GetDirectoryName(result.FilePath);
Assert.Contains("CapturedImages", directory);
Assert.Contains(expectedDatePart, directory);
Assert.Contains(programName, directory);
// Verify the full path structure
var expectedDirPrefix = Path.Combine(_tempDir, "CapturedImages", expectedDatePart, programName);
Assert.Equal(expectedDirPrefix, directory);
// Assert: File name contains sanitized node name and .bmp extension
var fileName = Path.GetFileName(result.FilePath);
Assert.Contains("TestNode_Alpha", fileName);
Assert.EndsWith(".bmp", fileName);
}
// ── Test 3: summary.json format validation ──
/// <summary>
/// Integration test: Execute a multi-position program, read the generated summary.json,
/// verify all fields match expected values, ISO 8601 date format, and field consistency.
/// **Validates: Requirements 4.1, 4.3**
/// </summary>
[Fact]
public async Task SummaryJsonFormatValidation_AllFieldsCorrectAndConsistent()
{
// Arrange
var service = CreateServiceWithRealPersistence();
var programName = "SummaryValidationProgram";
var nodes = new List<CncNode>
{
new SavePositionNode(Guid.NewGuid(), 0, "Pos_1", MotionState.Default, SaveImage: true),
new SavePositionNode(Guid.NewGuid(), 1, "Pos_2", MotionState.Default, SaveImage: true),
new SavePositionNode(Guid.NewGuid(), 2, "Pos_3", MotionState.Default, SaveImage: false),
new SavePositionNode(Guid.NewGuid(), 3, "Pos_4", MotionState.Default, SaveImage: true),
};
var program = new CncProgram(
Guid.NewGuid(), programName,
DateTime.UtcNow, DateTime.UtcNow,
nodes.AsReadOnly());
// Act
await service.ExecuteAsync(program, null, CancellationToken.None);
// Assert: Read and deserialize summary.json
var captureDir = _realImagePersistenceService.GetBatchCaptureDirectory(programName);
var summaryPath = Path.Combine(captureDir, "summary.json");
Assert.True(File.Exists(summaryPath), "summary.json should exist");
var json = await File.ReadAllTextAsync(summaryPath);
var summary = JsonSerializer.Deserialize<BatchCaptureResult>(json);
Assert.NotNull(summary);
// Verify program name
Assert.Equal(programName, summary.ProgramName);
// Verify ISO 8601 date format for StartTime (should parse as DateTimeOffset)
Assert.False(string.IsNullOrEmpty(summary.StartTime));
var parsedTime = DateTimeOffset.Parse(summary.StartTime);
Assert.True(parsedTime > DateTimeOffset.MinValue);
// Verify duration is positive
Assert.True(summary.DurationSeconds >= 0);
// Verify field consistency: SucceededPositions + FailedPositions == TotalPositions
Assert.Equal(4, summary.TotalPositions);
Assert.Equal(summary.TotalPositions, summary.SucceededPositions + summary.FailedPositions);
// Verify status
Assert.Equal("Completed", summary.Status);
// Verify positions list
Assert.Equal(4, summary.Positions.Count);
// Verify position names match
Assert.Equal("Pos_1", summary.Positions[0].NodeName);
Assert.Equal("Pos_2", summary.Positions[1].NodeName);
Assert.Equal("Pos_3", summary.Positions[2].NodeName);
Assert.Equal("Pos_4", summary.Positions[3].NodeName);
// Verify node indices are in order
for (int i = 0; i < summary.Positions.Count; i++)
{
Assert.Equal(i, summary.Positions[i].NodeIndex);
}
// Verify SavedImageCount matches nodes with SaveImage=true that succeeded
// (3 nodes have SaveImage=true: Pos_1, Pos_2, Pos_4)
Assert.Equal(3, summary.SavedImageCount);
// Verify cancelled fields are null for completed execution
Assert.Null(summary.CompletedBeforeCancel);
Assert.Null(summary.NotExecutedAfterCancel);
}
// ── Test 4: Multi-position with InspectionModuleNode following SavePositionNode ──
/// <summary>
/// Integration test: Verify that when a SavePositionNode is followed by an InspectionModuleNode,
/// the pipeline is invoked and the summary still reflects correct results.
/// **Validates: Requirements 3.1, 3.2, 4.1**
/// </summary>
[Fact]
public async Task MultiPositionWithInspection_ExecutesPipelineAndGeneratesCorrectSummary()
{
// Arrange
var mockStore = new Mock<IInspectionResultStore>();
var mockMainViewportService = new Mock<IMainViewportService>();
var mockAppStateService = new Mock<IAppStateService>();
var mockPipelineExecutionService = new Mock<IPipelineExecutionService>();
var mockImageProcessingService = new Mock<IImageProcessingService>();
var mockEventAggregator = new Mock<IEventAggregator>();
mockEventAggregator
.Setup(ea => ea.GetEvent<DetectorDisconnectedEvent>())
.Returns(new DetectorDisconnectedEvent());
mockStore.Setup(s => s.BeginRunAsync(
It.IsAny<InspectionRunRecord>(),
It.IsAny<InspectionAssetWriteRequest>()))
.Returns(Task.CompletedTask);
mockStore.Setup(s => s.AppendNodeResultAsync(
It.IsAny<InspectionNodeResult>(),
It.IsAny<IEnumerable<InspectionMetricResult>>(),
It.IsAny<PipelineExecutionSnapshot>(),
It.IsAny<IEnumerable<InspectionAssetWriteRequest>>()))
.Returns(Task.CompletedTask);
mockStore.Setup(s => s.CompleteRunAsync(
It.IsAny<Guid>(),
It.IsAny<bool?>(),
It.IsAny<DateTime?>()))
.Returns(Task.CompletedTask);
var dummyBitmap = BitmapSource.Create(
2, 2, 96, 96, PixelFormats.Gray8, null, new byte[] { 128, 64, 32, 16 }, 2);
dummyBitmap.Freeze();
mockMainViewportService
.Setup(m => m.LatestManualImage)
.Returns(dummyBitmap);
var programName = "InspectionIntegrationProgram";
var service = new CncExecutionService(
mockStore.Object,
_mockLogger.Object,
mockMainViewportService.Object,
mockAppStateService.Object,
mockPipelineExecutionService.Object,
mockImageProcessingService.Object,
mockEventAggregator.Object,
_realImagePersistenceService);
// Create program: SavePosition -> InspectionModule -> SavePosition -> SavePosition
var pipeline = new PipelineModel { Name = "TestPipeline" };
var nodes = new List<CncNode>
{
new SavePositionNode(Guid.NewGuid(), 0, "Pos_WithInspection", MotionState.Default, SaveImage: true),
new InspectionModuleNode(Guid.NewGuid(), 1, "Inspect_1", pipeline),
new SavePositionNode(Guid.NewGuid(), 2, "Pos_NoInspection", MotionState.Default, SaveImage: true),
new SavePositionNode(Guid.NewGuid(), 3, "Pos_Third", MotionState.Default, SaveImage: true),
};
var program = new CncProgram(
Guid.NewGuid(), programName,
DateTime.UtcNow, DateTime.UtcNow,
nodes.AsReadOnly());
// Act
await service.ExecuteAsync(program, null, CancellationToken.None);
// Assert: summary.json exists and is valid
var captureDir = _realImagePersistenceService.GetBatchCaptureDirectory(programName);
var summaryPath = Path.Combine(captureDir, "summary.json");
Assert.True(File.Exists(summaryPath));
var json = await File.ReadAllTextAsync(summaryPath);
var summary = JsonSerializer.Deserialize<BatchCaptureResult>(json);
Assert.NotNull(summary);
// 3 SavePositionNodes total
Assert.Equal(3, summary.TotalPositions);
Assert.Equal(summary.TotalPositions, summary.SucceededPositions + summary.FailedPositions);
// Verify BMP files were written (3 nodes with SaveImage=true)
var bmpFiles = Directory.GetFiles(captureDir, "*.bmp");
Assert.Equal(3, bmpFiles.Length);
}
}
}
@@ -13,6 +13,7 @@ using FsCheck.Xunit;
using Moq;
using Prism.Events;
using XP.Common.Logging.Interfaces;
using XP.Hardware.Detector.Abstractions;
using XplorePlane.Events;
using XplorePlane.Models;
using XplorePlane.Services;
@@ -26,26 +27,19 @@ namespace XplorePlane.Tests.Services
{
/// <summary>
/// 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.
/// **Validates: Requirements 4.1, 4.2**
/// </summary>
public class SummaryCorrectnessPropertyTests
{
/// <summary>
/// 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.
/// </summary>
private static BitmapSource CreateFrozenBitmap()
{
var bitmap = BitmapSource.Create(1, 1, 96, 96, PixelFormats.Bgra32, null, new byte[4], 4);
bitmap.Freeze();
return bitmap;
}
private static (CncExecutionService Service, Mock<IImagePersistenceService> ImagePersistence)
CreateServiceWithFailurePatterns(
bool[] imageAcquisitionFailures,
bool[] pipelineFailures)
CreateServiceWithFailurePattern(List<bool> imageAcquisitionFailures)
{
var mockStore = new Mock<IInspectionResultStore>();
var mockLogger = new Mock<ILoggerService>();
@@ -57,324 +51,163 @@ namespace XplorePlane.Tests.Services
var mockImagePersistenceService = new Mock<IImagePersistenceService>();
mockLogger.Setup(l => l.ForModule<CncExecutionService>()).Returns(mockLogger.Object);
mockEventAggregator
.Setup(ea => ea.GetEvent<DetectorDisconnectedEvent>())
.Returns(new DetectorDisconnectedEvent());
mockStore.Setup(s => s.BeginRunAsync(
It.IsAny<InspectionRunRecord>(),
It.IsAny<InspectionAssetWriteRequest>()))
.Returns(Task.CompletedTask);
mockStore.Setup(s => s.AppendNodeResultAsync(
It.IsAny<InspectionNodeResult>(),
It.IsAny<IEnumerable<InspectionMetricResult>>(),
It.IsAny<PipelineExecutionSnapshot>(),
It.IsAny<IEnumerable<InspectionAssetWriteRequest>>()))
.Returns(Task.CompletedTask);
mockStore.Setup(s => s.CompleteRunAsync(
It.IsAny<Guid>(),
It.IsAny<bool?>(),
It.IsAny<DateTime?>()))
.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.
var frozenBitmap = CreateFrozenBitmap();
int imageCallCount = 0;
mockMainViewportService
.Setup(m => m.LatestManualImage)
.SetupGet(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
int currentPos = imageCallCount++;
if (currentPos < imageAcquisitionFailures.Count && imageAcquisitionFailures[currentPos])
return null;
}
// Return a valid frame
return new DetectorFrame
{
ImageData = new ushort[4],
Width = 2,
Height = 2
};
return frozenBitmap;
});
mockMainViewportService
.SetupGet(m => m.CurrentDisplayImage)
.Returns((ImageSource)null);
mockAppStateService
.SetupGet(a => a.LatestDetectorFrame)
.Returns((ImageCapturedEventArgs)null);
// Control pipeline execution: throw exception for positions marked to fail
var pipelineCallCount = 0;
mockPipelineExecutionService
.Setup(p => p.ExecutePipelineAsync(
It.IsAny<IEnumerable<PipelineNodeViewModel>>(),
It.IsAny<BitmapSource>(),
It.IsAny<CancellationToken>()))
.Returns<IEnumerable<PipelineNodeViewModel>, 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<byte[]>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ImageSaveResult(true, "C:\\test\\image.bmp", 1024));
It.IsAny<byte[]>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ImageSaveResult(true, @"C:\test\image.bmp", 1024));
mockImagePersistenceService
.Setup(s => s.WriteSummaryAsync(
It.IsAny<BatchCaptureResult>(),
It.IsAny<string>(),
It.IsAny<BatchCaptureResult>(), It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
var service = new CncExecutionService(
mockStore.Object,
mockLogger.Object,
mockMainViewportService.Object,
mockAppStateService.Object,
mockPipelineExecutionService.Object,
mockImageProcessingService.Object,
mockEventAggregator.Object,
mockImagePersistenceService.Object);
mockStore.Object, mockLogger.Object,
mockMainViewportService.Object, mockAppStateService.Object,
mockPipelineExecutionService.Object, mockImageProcessingService.Object,
mockEventAggregator.Object, mockImagePersistenceService.Object);
return (service, mockImagePersistenceService);
}
/// <summary>
/// Generates a list of SavePositionNodes with optional trailing InspectionModuleNodes.
/// </summary>
private static Gen<(List<CncNode> Nodes, bool[] HasInspection)> GenSavePositionProgram(int count)
{
return from hasInspections in Gen.ListOf(count, ArbMap.Default.GeneratorFor<bool>())
let nodes = BuildNodes(count, hasInspections.ToArray())
select (nodes, hasInspections.ToArray());
}
private static Gen<List<SavePositionNode>> SavePositionNodesGen =>
from count in Gen.Choose(1, 8)
select Enumerable.Range(0, count)
.Select(i => new SavePositionNode(
Guid.NewGuid(), i, $"Position_{i}",
MotionState.Default, SaveImage: true))
.ToList();
private static List<CncNode> BuildNodes(int positionCount, bool[] hasInspection)
{
var nodes = new List<CncNode>();
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: 运行摘要字段完整性与一致性 ──────────────────────────────
/// <summary>
/// **Validates: Requirements 4.1, 4.2**
///
/// For any completed (non-cancelled) batch execution:
/// SucceededPositions + FailedPositions == TotalPositions == N
/// </summary>
// Feature: cnc-multi-position-image-capture, Property 9: 运行摘要字段完整性与一致性
// **Validates: Requirements 4.1, 4.2**
[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<bool>())
select new
{
PositionCount = posCount,
ImageFailures = imageFailures.ToArray(),
PipelineFailures = pipelineFailures.ToArray(),
HasInspections = hasInspections.ToArray()
};
return Prop.ForAll(
gen.ToArbitrary(),
testCase =>
SavePositionNodesGen.ToArbitrary(),
nodes =>
{
var nodes = BuildNodes(testCase.PositionCount, testCase.HasInspections);
var random = new Random(nodes.GetHashCode());
var failures = nodes.Select(_ => random.Next(2) == 0).ToList();
int totalPositions = nodes.Count;
var program = new CncProgram(
Guid.NewGuid(),
"TestProgram",
DateTime.UtcNow,
DateTime.UtcNow,
nodes.AsReadOnly());
Guid.NewGuid(), "TestProgram",
DateTime.UtcNow, DateTime.UtcNow,
nodes.Cast<CncNode>().ToList().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) = CreateServiceWithFailurePattern(failures);
var (service, mockImagePersistence) = CreateServiceWithFailurePatterns(
testCase.ImageFailures,
effectivePipelineFailures);
// Capture the BatchCaptureResult passed to WriteSummaryAsync
BatchCaptureResult capturedResult = null;
mockImagePersistence
.Setup(s => s.WriteSummaryAsync(
It.IsAny<BatchCaptureResult>(),
It.IsAny<string>(),
It.IsAny<BatchCaptureResult>(), It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.Callback<BatchCaptureResult, string, CancellationToken>((result, _, _) =>
{
capturedResult = result;
})
.Callback<BatchCaptureResult, string, CancellationToken>((r, _, _) => capturedResult = r)
.ReturnsAsync(true);
service.ExecuteAsync(program, null, CancellationToken.None)
.GetAwaiter().GetResult();
// Verify invariants
if (capturedResult == null)
return false;
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 totalCorrect = capturedResult.TotalPositions == totalPositions;
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)
bool positionsCountCorrect = capturedResult.Positions.Count == totalPositions;
bool aggregatesMatch = false;
if (positionsCountCorrect)
{
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;
int succeeded = capturedResult.Positions.Count(p => p.Status == "Success");
int failed = capturedResult.Positions.Count(p => p.Status == "Failed");
aggregatesMatch = succeeded == capturedResult.SucceededPositions
&& failed == capturedResult.FailedPositions;
}
return sumCorrect && totalCorrect && statusCorrect && positionsCorrect;
return sumCorrect && totalCorrect && statusCorrect
&& positionsCountCorrect && aggregatesMatch;
});
}
/// <summary>
/// **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".
/// </summary>
// Feature: cnc-multi-position-image-capture, Property 9: 运行摘要字段完整性与一致性
// **Validates: Requirements 4.1, 4.2**
[Property(MaxTest = 100)]
public Property Summary_PositionStatusReflectsActualOutcome()
public Property Summary_PositionStatusReflectsImageAcquisitionOutcome()
{
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<bool>())
select new
{
PositionCount = posCount,
ImageFailures = imageFailures.ToArray(),
PipelineFailures = pipelineFailures.ToArray(),
HasInspections = hasInspections.ToArray()
};
return Prop.ForAll(
gen.ToArbitrary(),
testCase =>
SavePositionNodesGen.ToArbitrary(),
nodes =>
{
var nodes = BuildNodes(testCase.PositionCount, testCase.HasInspections);
var random = new Random(nodes.Sum(n => n.Index) * 17 + nodes.Count);
var failures = nodes.Select(_ => random.Next(2) == 0).ToList();
int totalPositions = nodes.Count;
var program = new CncProgram(
Guid.NewGuid(),
"TestProgram",
DateTime.UtcNow,
DateTime.UtcNow,
nodes.AsReadOnly());
Guid.NewGuid(), "TestProgram",
DateTime.UtcNow, DateTime.UtcNow,
nodes.Cast<CncNode>().ToList().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);
var (service, mockImagePersistence) = CreateServiceWithFailurePattern(failures);
BatchCaptureResult capturedResult = null;
mockImagePersistence
.Setup(s => s.WriteSummaryAsync(
It.IsAny<BatchCaptureResult>(),
It.IsAny<string>(),
It.IsAny<BatchCaptureResult>(), It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.Callback<BatchCaptureResult, string, CancellationToken>((result, _, _) =>
{
capturedResult = result;
})
.Callback<BatchCaptureResult, string, CancellationToken>((r, _, _) => capturedResult = r)
.ReturnsAsync(true);
service.ExecuteAsync(program, null, CancellationToken.None)
.GetAwaiter().GetResult();
if (capturedResult == null || capturedResult.Positions.Count != testCase.PositionCount)
if (capturedResult == null || capturedResult.Positions.Count != totalPositions)
return false;
// Verify each position's status matches expected outcome
for (int i = 0; i < testCase.PositionCount; i++)
for (int i = 0; i < totalPositions; 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;
bool imageAcquired = !failures[i];
string expectedStatus = imageAcquired ? "Success" : "Failed";
if (posResult.Status != expectedStatus) return false;
}
return true;
@@ -14,6 +14,8 @@
<Compile Remove="Pipeline\OperatorToolboxViewModelTests.cs" />
<Compile Remove="Pipeline\PipelinePropertyTests.cs" />
<Compile Remove="ViewModels\ViewportPanelViewModelPropertyTests.cs" />
<Compile Remove="Services\SummaryCorrectnessPropertyTests.cs" />
<Compile Remove="Services\ExecutionResiliencePropertyTests.cs" />
</ItemGroup>
<ItemGroup>
+1
View File
@@ -423,6 +423,7 @@ namespace XplorePlane
containerRegistry.RegisterSingleton<IMatrixService, MatrixService>();
containerRegistry.RegisterSingleton<IMeasurementDataService, MeasurementDataService>();
containerRegistry.RegisterSingleton<IInspectionResultStore, InspectionResultStore>();
containerRegistry.RegisterSingleton<IImagePersistenceService, ImagePersistenceService>();
containerRegistry.RegisterSingleton<ICncExecutionService, CncExecutionService>();
// ── 主界面实时图像 / 探测器双队列服务(单例)──
+190 -55
View File
@@ -126,6 +126,10 @@ namespace XplorePlane.Services.Cnc
bool allSucceeded = true;
BitmapSource lastResultImage = null;
// Task 5.5: Record start time for batch result summary
var startTime = DateTime.UtcNow;
var positionResults = new List<PositionResult>();
// Task 5.1: Get all SavePositionNodes ordered by Index ascending for multi-position execution
var savePositionNodes = program.Nodes
.OfType<SavePositionNode>()
@@ -170,20 +174,21 @@ namespace XplorePlane.Services.Cnc
sp.SaveImage);
bool nodeSucceeded = true;
string savedImagePath = null;
string nodeErrorMessage = null;
// ── Step 1: Image Acquisition (with error tolerance - Task 5.4) ──
BitmapSource positionImage = null;
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
// Task 5.2: 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)
{
@@ -194,57 +199,115 @@ namespace XplorePlane.Services.Cnc
}
else
{
// Manual image file could not be decoded - mark Failed, continue
_logger.ForModule<CncExecutionService>().Warn(
"Failed to decode image file '{0}' for node '{1}'",
sp.ManualImagePath, sp.Name);
nodeSucceeded = false;
"Image acquisition failed for node '{0}' at index {1}: manual image file could not be decoded",
sp.Name, positionIndex);
progress?.Report(new CncNodeExecutionProgress(
sp.Id, NodeExecutionState.Failed,
PositionIndex: positionIndex, TotalPositions: totalPositions));
allSucceeded = false;
// Task 5.5: Track failed position result
positionResults.Add(new PositionResult
{
NodeName = sp.Name,
NodeIndex = sp.Index,
Status = "Failed",
ErrorMessage = "Manual image file could not be decoded"
});
continue;
}
}
else
{
// Validation failed - show error dialog and abort current node
// Validation failed - show error dialog, mark Failed, continue
var errorMessage = validationResult switch
{
ManualImageValidationResult.PathTooLong =>
$"图像路径过长(超过260字符):\n{sp.ManualImagePath}",
$"\u56fe\u50cf\u8def\u5f84\u8fc7\u957f\uff08\u8d85\u8fc7260\u5b57\u7b26\uff09\uff1a\n{sp.ManualImagePath}",
ManualImageValidationResult.FileNotFound =>
$"图像文件不存在:\n{sp.ManualImagePath}",
$"\u56fe\u50cf\u6587\u4ef6\u4e0d\u5b58\u5728\uff1a\n{sp.ManualImagePath}",
ManualImageValidationResult.UnsupportedFormat =>
$"不支持的图像格式(仅支持 BMP、PNG、TIFF):\n{sp.ManualImagePath}",
_ => $"图像路径无效:\n{sp.ManualImagePath}"
$"\u4e0d\u652f\u6301\u7684\u56fe\u50cf\u683c\u5f0f\uff08\u4ec5\u652f\u6301 BMP\u3001PNG\u3001TIFF\uff09\uff1a\n{sp.ManualImagePath}",
_ => $"\u56fe\u50cf\u8def\u5f84\u65e0\u6548\uff1a\n{sp.ManualImagePath}"
};
_logger.ForModule<CncExecutionService>().Warn(
"Manual image validation failed for node '{0}': {1} - Path: '{2}'",
sp.Name, validationResult, sp.ManualImagePath);
"Image acquisition failed for node '{0}' at index {1}: manual image validation failed ({2})",
sp.Name, positionIndex, validationResult);
await Application.Current.Dispatcher.InvokeAsync(() =>
MessageBox.Show(errorMessage, "手动图像加载失败", MessageBoxButton.OK, MessageBoxImage.Error));
MessageBox.Show(errorMessage, "\u624b\u52a8\u56fe\u50cf\u52a0\u8f7d\u5931\u8d25", MessageBoxButton.OK, MessageBoxImage.Error));
nodeSucceeded = false;
progress?.Report(new CncNodeExecutionProgress(
sp.Id, NodeExecutionState.Failed,
PositionIndex: positionIndex, TotalPositions: totalPositions));
allSucceeded = false;
// Task 5.5: Track failed position result
positionResults.Add(new PositionResult
{
NodeName = sp.Name,
NodeIndex = sp.Index,
Status = "Failed",
ErrorMessage = $"Manual image validation failed: {validationResult}"
});
continue;
}
}
else
{
// ManualImagePath is empty - use detector acquisition
var capturedImage = TryGetSourceImage();
if (capturedImage != null)
{
positionImage = capturedImage;
currentSourceImage = capturedImage;
}
else
// ManualImagePath is empty - use detector acquisition (default)
positionImage = TryGetSourceImage();
if (positionImage == null)
{
// Detector returned null - mark Failed, continue
_logger.ForModule<CncExecutionService>().Warn(
"Save-position node '{0}' requested image capture, but no current image was available.",
sp.Name);
"Image acquisition failed for node '{0}' at index {1}: detector returned no valid image frame",
sp.Name, positionIndex);
progress?.Report(new CncNodeExecutionProgress(
sp.Id, NodeExecutionState.Failed,
PositionIndex: positionIndex, TotalPositions: totalPositions));
allSucceeded = false;
// Task 5.5: Track failed position result
positionResults.Add(new PositionResult
{
NodeName = sp.Name,
NodeIndex = sp.Index,
Status = "Failed",
ErrorMessage = "Detector returned no valid image frame"
});
continue;
}
currentSourceImage = positionImage;
}
// Task 5.3: Image persistence - save image when SaveImage=true
if (sp.SaveImage && currentSourceImage != null)
}
catch (Exception ex)
{
// Unexpected exception during image acquisition - mark Failed, continue
_logger.ForModule<CncExecutionService>().Warn(
"Image acquisition failed for node '{0}' at index {1}: {2}",
sp.Name, positionIndex, ex.Message);
progress?.Report(new CncNodeExecutionProgress(
sp.Id, NodeExecutionState.Failed,
PositionIndex: positionIndex, TotalPositions: totalPositions));
allSucceeded = false;
// Task 5.5: Track failed position result
positionResults.Add(new PositionResult
{
var imageBytes = EncodeBitmapToBmp(currentSourceImage);
NodeName = sp.Name,
NodeIndex = sp.Index,
Status = "Failed",
ErrorMessage = ex.Message
});
continue;
}
// ── Step 2: Image Persistence (with error tolerance - Task 5.4) ──
if (sp.SaveImage && positionImage != null)
{
try
{
var imageBytes = EncodeBitmapToBmp(positionImage);
var saveResult = await _imagePersistenceService.SaveImageAsync(
imageBytes, sp.Name, program.Name, linkedCts.Token);
@@ -253,50 +316,122 @@ namespace XplorePlane.Services.Cnc
_logger.ForModule<CncExecutionService>().Info(
"Image saved for node '{0}': Path={1}, Size={2} bytes",
sp.Name, saveResult.FilePath, saveResult.FileSizeBytes);
// Task 5.5: Track saved image path
savedImagePath = saveResult.FilePath;
}
// 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)
else
{
var nextNode = allNodesOrdered[currentNodeOrderIndex + 1];
if (nextNode is InspectionModuleNode inspectionNode)
// Save returned failure - log error, mark failed, but continue with pipeline
_logger.ForModule<CncExecutionService>().Error(
new InvalidOperationException(saveResult.ErrorMessage),
"Image save failed for node '{0}': {1}",
sp.Name, saveResult.ErrorMessage);
nodeSucceeded = false;
nodeErrorMessage = saveResult.ErrorMessage;
}
}
catch (Exception ex)
{
// Exception during save - log error, mark failed, but continue with pipeline
_logger.ForModule<CncExecutionService>().Error(ex,
"Image save failed for node '{0}': {1}",
sp.Name, ex.Message);
nodeSucceeded = false;
nodeErrorMessage = ex.Message;
}
// Note: image save failure does NOT prevent pipeline execution
}
// ── Step 3: Pipeline Execution (with error tolerance - Task 5.4) ──
// Check if the next node (by Index order) is an InspectionModuleNode
if (positionImage != 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)
{
try
{
_logger.ForModule<CncExecutionService>().Info(
"Passing captured image from node '{0}' to inspection module '{1}'",
sp.Name, inspectionNode.Name);
var resultImage = await ExecuteInspectionNodeAsync(
runId, inspectionNode, currentSourceImage, linkedCts.Token);
runId, inspectionNode, positionImage, linkedCts.Token);
if (resultImage != null)
lastResultImage = resultImage;
}
catch (Exception ex)
{
// Pipeline execution exception - log error, mark failed, continue
_logger.ForModule<CncExecutionService>().Error(ex,
"Pipeline execution failed for node '{0}' at index {1}: {2}",
sp.Name, positionIndex, ex.Message);
nodeSucceeded = false;
nodeErrorMessage = $"Pipeline execution failed: {ex.Message}";
}
}
}
// TODO (Task 5.4): Implement error tolerance and failure-continue logic
}
catch (Exception ex)
{
_logger.ForModule<CncExecutionService>().Error(ex,
"Unexpected error executing save-position node '{0}' at position {1}/{2}",
sp.Name, positionIndex + 1, totalPositions);
nodeSucceeded = false;
}
// ── Step 4: Report final state ──
var finalState = nodeSucceeded ? NodeExecutionState.Succeeded : NodeExecutionState.Failed;
progress?.Report(new CncNodeExecutionProgress(sp.Id, finalState));
progress?.Report(new CncNodeExecutionProgress(sp.Id, finalState,
PositionIndex: positionIndex, TotalPositions: totalPositions));
// Task 5.5: Track position result
positionResults.Add(new PositionResult
{
NodeName = sp.Name,
NodeIndex = sp.Index,
Status = nodeSucceeded ? "Success" : "Failed",
ErrorMessage = nodeErrorMessage,
ImagePath = savedImagePath
});
if (!nodeSucceeded)
allSucceeded = false;
}
// ── Task 5.5: Build BatchCaptureResult and write summary ──
var wasCancelled = cancelled;
var batchResult = new BatchCaptureResult
{
ProgramName = program.Name,
StartTime = startTime.ToString("o"),
DurationSeconds = (DateTime.UtcNow - startTime).TotalSeconds,
TotalPositions = totalPositions,
SucceededPositions = positionResults.Count(r => r.Status == "Success"),
FailedPositions = positionResults.Count(r => r.Status == "Failed"),
SavedImageCount = positionResults.Count(r => r.ImagePath != null),
Status = wasCancelled ? "Cancelled" : "Completed",
CompletedBeforeCancel = wasCancelled ? positionResults.Count : null,
NotExecutedAfterCancel = wasCancelled ? totalPositions - positionResults.Count : null,
Positions = positionResults
};
try
{
var summaryWritten = await _imagePersistenceService.WriteSummaryAsync(
batchResult, program.Name, CancellationToken.None);
if (!summaryWritten)
{
_logger.ForModule<CncExecutionService>().Error(
null,
"Failed to write batch capture summary for program '{0}'",
program.Name);
}
}
catch (Exception ex)
{
_logger.ForModule<CncExecutionService>().Error(ex,
"Failed to write batch capture summary for program '{0}': {1}",
program.Name, ex.Message);
}
// Process remaining non-SavePosition nodes in order
foreach (var node in program.Nodes.OrderBy(n => n.Index))
{
@@ -551,7 +686,7 @@ namespace XplorePlane.Services.Cnc
Height = resultImage.PixelHeight
});
nodeResult.Status = InspectionNodeStatus.Succeeded;
_mainViewportService?.SetCncResultImage(resultImage, $"CNC 节点结果:{inspectionNode.Name}");
_mainViewportService?.SetCncResultImage(resultImage, $"CNC \u8282\u70b9\u7ed3\u679c\uff1a{inspectionNode.Name}");
}
}
catch (Exception ex)