Files
XplorePlane/XplorePlane.Tests/Services/ImagePersistenceCallPropertyTests.cs
T
2026-05-15 15:29:53 +08:00

302 lines
13 KiB
C#

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