#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;
});
}
}
}