位置节点增加保存图像到本地的功能;支持输入图像

This commit is contained in:
zhengxuan.zhang
2026-05-15 13:44:37 +08:00
parent bc8a0eadfb
commit f07d891346
23 changed files with 3549 additions and 34 deletions
@@ -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
{
/// <summary>
/// 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.
/// </summary>
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<string> NonEmptyStringGen =>
ArbMap.Default.GeneratorFor<NonEmptyString>().Select(s => s.Get);
private static Gen<PipelineModel> PipelineGen =>
from name in NonEmptyStringGen
select new PipelineModel
{
Name = name,
Nodes = new List<PipelineNodeModel>
{
new PipelineNodeModel { OperatorKey = "test_op", Order = 0, IsEnabled = true }
}
};
/// <summary>
/// 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.
/// </summary>
private static Gen<CncProgram> CncProgramWithMixedFollowersGen()
{
return from saveCount in Gen.Choose(1, 5)
from followFlags in Gen.ListOf<bool>(ArbMap.Default.GeneratorFor<bool>(), 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<CncNode> BuildMixedNodes(int saveCount, IReadOnlyList<bool> followFlags)
{
var nodes = new List<CncNode>();
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<PipelineNodeModel>
{
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<IPipelineExecutionService> PipelineExec,
Mock<IMainViewportService> MainViewport,
Mock<IImagePersistenceService> ImagePersistence,
Mock<ILoggerService> Logger)
CreateServiceWithCapture(BitmapSource detectorImage)
{
var mockStore = new Mock<IInspectionResultStore>();
var mockLogger = new Mock<ILoggerService>();
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>();
var mockImagePersistenceService = new Mock<IImagePersistenceService>();
mockLogger.Setup(l => l.ForModule<CncExecutionService>()).Returns(mockLogger.Object);
mockEventAggregator
.Setup(ea => ea.GetEvent<DetectorDisconnectedEvent>())
.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<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);
mockImagePersistenceService
.Setup(s => s.SaveImageAsync(
It.IsAny<byte[]>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ImageSaveResult(true, "test.bmp", 100));
mockImagePersistenceService
.Setup(s => s.WriteSummaryAsync(
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);
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<IEnumerable<PipelineNodeViewModel>>(),
It.IsAny<BitmapSource>(),
It.IsAny<IProgress<PipelineProgress>>(),
It.IsAny<CancellationToken>()))
.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));
})
.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<SavePositionNode>().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<IEnumerable<PipelineNodeViewModel>>(),
It.IsAny<BitmapSource>(),
It.IsAny<IProgress<PipelineProgress>>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<PipelineNodeViewModel>, BitmapSource, IProgress<PipelineProgress>, 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<CncNode> BuildNodesWithoutFollowingInspection(int saveCount)
{
var nodes = new List<CncNode>();
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;
}
}
}