using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FsCheck;
using FsCheck.Fluent;
using FsCheck.Xunit;
using Moq;
using XP.Common.Logging.Interfaces;
using XplorePlane.Models;
using XplorePlane.Services.Cnc;
using XplorePlane.Services.InspectionResults;
using Xunit;
namespace XplorePlane.Tests.Services
{
// ── SynchronousProgress ──────────────────────────────────────────────────────
///
/// A synchronous IProgress<T> implementation that invokes the callback inline
/// (on the calling thread), avoiding async dispatch timing issues in tests.
///
internal sealed class SynchronousProgress : IProgress
{
private readonly Action _handler;
public SynchronousProgress(Action handler) => _handler = handler;
public void Report(T value) => _handler(value);
}
// ── Generators ────────────────────────────────────────────────────────────
///
/// FsCheck generators for CNC program models.
/// Avoids PauseDialogNode (requires Application.Current.Dispatcher).
///
public static class CncProgramGenerators
{
private static Gen NonEmptyStringGen =>
ArbMap.Default.GeneratorFor().Select(s => s.Get);
private static Gen PipelineGen =>
from name in NonEmptyStringGen
select new PipelineModel { Name = name };
/// Generates a random CncNode (WaitDelayNode, InspectionModuleNode, CompleteProgramNode, or ReferencePointNode).
public static Gen CncNodeGen(int index) =>
Gen.Choose(0, 3).SelectMany(kind =>
{
switch (kind)
{
case 0:
// WaitDelayNode with 0ms delay so tests run fast
return Gen.Constant(
new WaitDelayNode(Guid.NewGuid(), index, $"Wait_{index}", 0));
case 1:
return from pipeline in PipelineGen
select (CncNode)new InspectionModuleNode(
Guid.NewGuid(), index, $"Inspect_{index}", pipeline);
case 2:
return Gen.Constant(
new CompleteProgramNode(Guid.NewGuid(), index, $"Complete_{index}"));
default:
return Gen.Constant(
new ReferencePointNode(Guid.NewGuid(), index, $"Ref_{index}",
0, 0, 0, 0, 0, 0, false, 0, 0));
}
});
/// Generates a CncProgram with minNodes..maxNodes random nodes, unique ascending indices.
public static Arbitrary CncProgramArb(int minNodes = 1, int maxNodes = 10)
{
var gen =
from count in Gen.Choose(minNodes, maxNodes)
from name in NonEmptyStringGen
from shuffleSeed in ArbMap.Default.GeneratorFor()
from nodes in GenNodes(count)
let shuffled = ShuffleByIndex(nodes, shuffleSeed)
select new CncProgram(
Guid.NewGuid(), name,
DateTime.UtcNow, DateTime.UtcNow,
shuffled.AsReadOnly());
return gen.ToArbitrary();
}
/// Generates a CncProgram with at least minInspection InspectionModuleNodes (no CompleteProgramNode).
public static Arbitrary CncProgramWithInspectionNodesArb(int minInspection = 1)
{
var gen =
from inspCount in Gen.Choose(minInspection, minInspection + 4)
from otherCount in Gen.Choose(0, 3)
from name in NonEmptyStringGen
from inspNodes in GenInspectionNodes(inspCount, 0)
from otherNodes in GenOtherNodes(otherCount, inspCount)
let allNodes = inspNodes.Concat(otherNodes).OrderBy(n => n.Index).ToList()
select new CncProgram(
Guid.NewGuid(), name,
DateTime.UtcNow, DateTime.UtcNow,
allNodes.AsReadOnly());
return gen.ToArbitrary();
}
private static Gen> GenNodes(int count)
{
if (count == 0)
return Gen.Constant(new List());
Gen> acc = Gen.Constant(new List());
for (int i = 0; i < count; i++)
{
var idx = i;
acc = from list in acc
from node in CncNodeGen(idx)
select new List(list) { node };
}
return acc;
}
private static Gen> GenInspectionNodes(int count, int startIndex)
{
Gen> acc = Gen.Constant(new List());
for (int i = 0; i < count; i++)
{
var idx = startIndex + i;
acc = from list in acc
from pipeline in PipelineGen
select new List(list)
{
new InspectionModuleNode(Guid.NewGuid(), idx, $"Inspect_{idx}", pipeline)
};
}
return acc;
}
private static Gen> GenOtherNodes(int count, int startIndex)
{
Gen> acc = Gen.Constant(new List());
for (int i = 0; i < count; i++)
{
var idx = startIndex + i;
acc = from list in acc
select new List(list)
{
new ReferencePointNode(Guid.NewGuid(), idx, $"Ref_{idx}",
0, 0, 0, 0, 0, 0, false, 0, 0)
};
}
return acc;
}
private static List ShuffleByIndex(List nodes, int seed)
{
// Reassign Index values in shuffled order so Index != position
var rng = new Random(seed);
var indices = Enumerable.Range(0, nodes.Count).OrderBy(_ => rng.Next()).ToList();
return nodes.Select((n, i) => RebuildWithIndex(n, indices[i])).ToList();
}
private static CncNode RebuildWithIndex(CncNode node, int newIndex) => node switch
{
WaitDelayNode w => w with { Index = newIndex },
InspectionModuleNode m => m with { Index = newIndex },
CompleteProgramNode c => c with { Index = newIndex },
ReferencePointNode r => r with { Index = newIndex },
_ => node
};
}
// ── Test Class ────────────────────────────────────────────────────────────
public class CncExecutionServiceTests
{
private static (CncExecutionService Service, Mock Store, Mock Logger)
CreateService()
{
var mockStore = new Mock();
var mockLogger = new Mock();
mockLogger.Setup(l => l.ForModule()).Returns(mockLogger.Object);
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);
var service = new CncExecutionService(mockStore.Object, mockLogger.Object);
return (service, mockStore, mockLogger);
}
// ── Property 3: 预取消立即返回 ────────────────────────────────────────
// Feature: cnc-run-execution, Property 3: 预取消立即返回
// Validates: Requirements 5.3
[Property(MaxTest = 100)]
public Property PreCancelled_NeverCallsBeginRunAsync()
{
return Prop.ForAll(
CncProgramGenerators.CncProgramArb(1, 10),
program =>
{
var (service, mockStore, _) = CreateService();
using var cts = new CancellationTokenSource();
cts.Cancel();
// Should return without throwing
var ex = Record.Exception(() =>
service.ExecuteAsync(program, null, cts.Token).GetAwaiter().GetResult());
bool noException = ex == null;
bool beginNotCalled = !mockStore.Invocations.Any(i => i.Method.Name == nameof(IInspectionResultStore.BeginRunAsync));
return noException && beginNotCalled;
});
}
// ── Property 4: 节点按 Index 升序执行 ────────────────────────────────
// Feature: cnc-run-execution, Property 4: 节点按 Index 升序执行
// Validates: Requirements 5.4
[Property(MaxTest = 100)]
public Property Nodes_ExecutedInAscendingIndexOrder()
{
return Prop.ForAll(
CncProgramGenerators.CncProgramArb(2, 8),
program =>
{
var (service, _, _) = CreateService();
var runningReports = new List();
// Use SynchronousProgress to avoid async callback timing issues
var progress = new SynchronousProgress(p =>
{
if (p.State == NodeExecutionState.Running)
runningReports.Add(p.NodeId);
});
service.ExecuteAsync(program, progress, CancellationToken.None)
.GetAwaiter().GetResult();
// Build expected order: nodes sorted by Index, stopping AFTER CompleteProgramNode
// (CompleteProgramNode itself gets a Running report before the loop breaks)
var orderedNodes = program.Nodes.OrderBy(n => n.Index).ToList();
var expectedIds = new List();
foreach (var node in orderedNodes)
{
expectedIds.Add(node.Id);
if (node is CompleteProgramNode)
break;
}
// runningReports must be a prefix-match of expectedIds in order
if (runningReports.Count > expectedIds.Count)
return false;
for (int i = 0; i < runningReports.Count; i++)
{
if (runningReports[i] != expectedIds[i])
return false;
}
return true;
});
}
// ── Property 5: CompleteProgramNode 终止后续执行 ──────────────────────
// Feature: cnc-run-execution, Property 5: CompleteProgramNode 终止后续执行
// Validates: Requirements 5.5
[Property(MaxTest = 100)]
public Property CompleteProgramNode_StopsSubsequentExecution()
{
// Generate programs that contain at least one CompleteProgramNode not at the last position
var gen =
from totalCount in Gen.Choose(3, 8)
from completePosRaw in Gen.Choose(0, totalCount - 2)
from name in ArbMap.Default.GeneratorFor()
let completePos = completePosRaw
from nodes in BuildProgramWithCompleteAt(totalCount, completePos)
select new CncProgram(Guid.NewGuid(), name.Get, DateTime.UtcNow, DateTime.UtcNow, nodes.AsReadOnly());
return Prop.ForAll(
gen.ToArbitrary(),
program =>
{
var (service, _, _) = CreateService();
var runningIds = new List();
var progress = new SynchronousProgress(p =>
{
if (p.State == NodeExecutionState.Running)
runningIds.Add(p.NodeId);
});
service.ExecuteAsync(program, progress, CancellationToken.None)
.GetAwaiter().GetResult();
var orderedNodes = program.Nodes.OrderBy(n => n.Index).ToList();
var completeNode = orderedNodes.First(n => n is CompleteProgramNode);
var nodesAfterComplete = orderedNodes
.Where(n => n.Index > completeNode.Index)
.Select(n => n.Id)
.ToHashSet();
// None of the nodes after CompleteProgramNode should have received Running
return !runningIds.Any(id => nodesAfterComplete.Contains(id));
});
}
private static Gen> BuildProgramWithCompleteAt(int total, int completePos)
{
Gen> acc = Gen.Constant(new List());
for (int i = 0; i < total; i++)
{
var idx = i;
var isComplete = (idx == completePos);
acc = from list in acc
let node = isComplete
? (CncNode)new CompleteProgramNode(Guid.NewGuid(), idx, $"Complete_{idx}")
: new ReferencePointNode(Guid.NewGuid(), idx, $"Ref_{idx}", 0, 0, 0, 0, 0, 0, false, 0, 0)
select new List(list) { node };
}
return acc;
}
// ── Property 6: BeginRunAsync 恰好调用一次 ────────────────────────────
// Feature: cnc-run-execution, Property 6: BeginRunAsync 恰好调用一次
// Validates: Requirements 4.1
[Property(MaxTest = 100)]
public Property BeginRunAsync_CalledExactlyOnce_WithCorrectProgramName()
{
return Prop.ForAll(
CncProgramGenerators.CncProgramArb(1, 8),
program =>
{
var (service, mockStore, _) = CreateService();
InspectionRunRecord capturedRecord = null;
mockStore.Setup(s => s.BeginRunAsync(
It.IsAny(),
It.IsAny()))
.Callback((r, _) => capturedRecord = r)
.Returns(Task.CompletedTask);
service.ExecuteAsync(program, null, CancellationToken.None)
.GetAwaiter().GetResult();
mockStore.Verify(s => s.BeginRunAsync(
It.IsAny(),
It.IsAny()), Times.Once);
return capturedRecord != null && capturedRecord.ProgramName == program.Name;
});
}
// ── Property 7: AppendNodeResultAsync 调用次数等于 InspectionModuleNode 数量 ──
// Feature: cnc-run-execution, Property 7: AppendNodeResultAsync 调用次数等于 InspectionModuleNode 数量
// Validates: Requirements 4.2
[Property(MaxTest = 100)]
public Property AppendNodeResultAsync_CalledExactlyNTimes()
{
return Prop.ForAll(
CncProgramGenerators.CncProgramArb(1, 10),
program =>
{
var (service, mockStore, _) = CreateService();
service.ExecuteAsync(program, null, CancellationToken.None)
.GetAwaiter().GetResult();
// Count InspectionModuleNodes before any CompleteProgramNode
var orderedNodes = program.Nodes.OrderBy(n => n.Index).ToList();
int expectedCount = 0;
foreach (var node in orderedNodes)
{
if (node is CompleteProgramNode) break;
if (node is InspectionModuleNode) expectedCount++;
}
mockStore.Verify(s => s.AppendNodeResultAsync(
It.IsAny(),
It.IsAny>(),
It.IsAny(),
It.IsAny>()), Times.Exactly(expectedCount));
return true;
});
}
// ── Property 8: NodeCount 等于 InspectionModuleNode 数量 ─────────────
// Feature: cnc-run-execution, Property 8: NodeCount 等于 InspectionModuleNode 数量
// Validates: Requirements 4.7
[Property(MaxTest = 100)]
public Property BeginRunAsync_NodeCount_EqualsInspectionModuleNodeCount()
{
return Prop.ForAll(
CncProgramGenerators.CncProgramArb(1, 10),
program =>
{
var (service, mockStore, _) = CreateService();
InspectionRunRecord capturedRecord = null;
mockStore.Setup(s => s.BeginRunAsync(
It.IsAny(),
It.IsAny()))
.Callback((r, _) => capturedRecord = r)
.Returns(Task.CompletedTask);
service.ExecuteAsync(program, null, CancellationToken.None)
.GetAwaiter().GetResult();
int expectedCount = program.Nodes.OfType().Count();
return capturedRecord != null && capturedRecord.NodeCount == expectedCount;
});
}
// ── Property 9: CompleteRunAsync 恰好调用一次 ─────────────────────────
// Feature: cnc-run-execution, Property 9: CompleteRunAsync 恰好调用一次
// Validates: Requirements 4.4, 4.5
[Property(MaxTest = 100)]
public Property CompleteRunAsync_CalledExactlyOnce_NormalCompletion()
{
return Prop.ForAll(
CncProgramGenerators.CncProgramArb(1, 8),
program =>
{
var (service, mockStore, _) = CreateService();
bool? capturedOverallPass = default;
bool callbackInvoked = false;
mockStore.Setup(s => s.CompleteRunAsync(
It.IsAny(),
It.IsAny(),
It.IsAny()))
.Callback((_, pass, __) =>
{
capturedOverallPass = pass;
callbackInvoked = true;
})
.Returns(Task.CompletedTask);
service.ExecuteAsync(program, null, CancellationToken.None)
.GetAwaiter().GetResult();
mockStore.Verify(s => s.CompleteRunAsync(
It.IsAny(),
It.IsAny(),
It.IsAny()), Times.Once);
// Normal completion: overallPass should not be null
return callbackInvoked && capturedOverallPass.HasValue;
});
}
[Fact]
// Feature: cnc-run-execution, Property 9 (cancellation path): CompleteRunAsync called with overallPass=null when cancelled
// Validates: Requirements 4.4, 4.5
public void CompleteRunAsync_CalledWithNullOverallPass_WhenCancelled()
{
var (service, mockStore, _) = CreateService();
// Use a WaitDelayNode with long delay so cancellation happens during execution
var waitNode = new WaitDelayNode(Guid.NewGuid(), 0, "LongWait", 5000);
var program = new CncProgram(
Guid.NewGuid(), "TestProgram",
DateTime.UtcNow, DateTime.UtcNow,
new List { waitNode }.AsReadOnly());
bool? capturedOverallPass = default;
bool callbackInvoked = false;
mockStore.Setup(s => s.CompleteRunAsync(
It.IsAny(),
It.IsAny(),
It.IsAny()))
.Callback((_, pass, __) =>
{
capturedOverallPass = pass;
callbackInvoked = true;
})
.Returns(Task.CompletedTask);
using var cts = new CancellationTokenSource();
// Cancel after 50ms — well before the 5000ms delay completes
cts.CancelAfter(50);
service.ExecuteAsync(program, null, cts.Token).GetAwaiter().GetResult();
mockStore.Verify(s => s.CompleteRunAsync(
It.IsAny(),
It.IsAny(),
It.IsAny()), Times.Once);
Assert.True(callbackInvoked);
Assert.Null(capturedOverallPass);
}
// ── Property 10: 归档异常不中断执行 ──────────────────────────────────
// Feature: cnc-run-execution, Property 10: 归档异常不中断执行
// Validates: Requirements 4.6
[Property(MaxTest = 100)]
public Property ArchiveException_DoesNotStopExecution()
{
return Prop.ForAll(
CncProgramGenerators.CncProgramWithInspectionNodesArb(2),
program =>
{
var (service, mockStore, _) = CreateService();
// Make AppendNodeResultAsync always throw
mockStore.Setup(s => s.AppendNodeResultAsync(
It.IsAny(),
It.IsAny>(),
It.IsAny(),
It.IsAny>()))
.ThrowsAsync(new Exception("Archive failure"));
var runningIds = new List();
var progress = new SynchronousProgress(p =>
{
if (p.State == NodeExecutionState.Running)
runningIds.Add(p.NodeId);
});
service.ExecuteAsync(program, progress, CancellationToken.None)
.GetAwaiter().GetResult();
// All non-CompleteProgramNode nodes should have received Running
// (CompleteProgramNode itself also gets Running before the loop breaks)
var orderedNodes = program.Nodes.OrderBy(n => n.Index).ToList();
var expectedIds = new List();
foreach (var node in orderedNodes)
{
expectedIds.Add(node.Id);
if (node is CompleteProgramNode) break;
}
bool allNodesRan = expectedIds.All(id => runningIds.Contains(id));
// CompleteRunAsync must still be called
mockStore.Verify(s => s.CompleteRunAsync(
It.IsAny(),
It.IsAny(),
It.IsAny()), Times.Once);
return allNodesRan;
});
}
// ── Property 13: WaitDelayNode 取消传播 ──────────────────────────────
// Feature: cnc-run-execution, Property 13: WaitDelayNode 取消传播
// Validates: Requirements 2.3
[Property(MaxTest = 100)]
public Property WaitDelayNode_CancellationPropagates_QuicklyStops()
{
var gen =
from delayMs in Gen.Choose(5000, 60000)
select new WaitDelayNode(Guid.NewGuid(), 0, "LongWait", delayMs);
return Prop.ForAll(
gen.ToArbitrary(),
waitNode =>
{
var (service, mockStore, _) = CreateService();
bool? capturedOverallPass = default;
mockStore.Setup(s => s.CompleteRunAsync(
It.IsAny(),
It.IsAny(),
It.IsAny()))
.Callback((_, pass, __) => capturedOverallPass = pass)
.Returns(Task.CompletedTask);
var program = new CncProgram(
Guid.NewGuid(), "WaitProgram",
DateTime.UtcNow, DateTime.UtcNow,
new List { waitNode }.AsReadOnly());
using var cts = new CancellationTokenSource();
cts.CancelAfter(50); // Cancel after 50ms
var sw = System.Diagnostics.Stopwatch.StartNew();
service.ExecuteAsync(program, null, cts.Token).GetAwaiter().GetResult();
sw.Stop();
// Must complete well within 2 seconds (not wait for the full delay)
bool completedQuickly = sw.Elapsed.TotalSeconds < 2.0;
// CompleteRunAsync must be called with overallPass=null (cancelled)
bool completeCalledWithNull = capturedOverallPass == null;
return completedQuickly && completeCalledWithNull;
});
}
// ── Property 11: 节点执行状态转换正确性 ──────────────────────────────
// Feature: cnc-run-execution, Property 11: 节点执行状态转换正确性
// Validates: Requirements 6.1, 6.2
[Property(MaxTest = 100)]
public Property NodeExecutionState_TransitionsCorrectly_RunningThenSucceeded()
{
return Prop.ForAll(
CncProgramGenerators.CncProgramArb(1, 8),
program =>
{
var (service, _, _) = CreateService();
// Build a map of NodeId → CncNodeViewModel
var nodeVms = program.Nodes
.ToDictionary(
n => n.Id,
n => new XplorePlane.ViewModels.Cnc.CncNodeViewModel(n, (_, __) => { }));
var runningSeenIds = new HashSet();
var succeededIds = new HashSet();
var progress = new SynchronousProgress(p =>
{
if (!nodeVms.TryGetValue(p.NodeId, out var vm)) return;
vm.ExecutionState = p.State;
if (p.State == NodeExecutionState.Running)
{
// When Running: IsRunningNode must be true, others false
if (!vm.IsRunningNode || vm.IsSucceededNode || vm.IsFailedNode)
runningSeenIds.Add(Guid.Empty); // sentinel for failure
else
runningSeenIds.Add(p.NodeId);
}
else if (p.State == NodeExecutionState.Succeeded)
{
// When Succeeded: IsSucceededNode must be true, others false
if (vm.IsSucceededNode && !vm.IsRunningNode && !vm.IsFailedNode)
succeededIds.Add(p.NodeId);
}
});
service.ExecuteAsync(program, progress, CancellationToken.None)
.GetAwaiter().GetResult();
// No sentinel failures
bool noRunningFailures = !runningSeenIds.Contains(Guid.Empty);
// Every node that received Running should also have received Succeeded
// (Running nodes that were reported Running must eventually be Succeeded)
var validRunningIds = runningSeenIds.Where(id => id != Guid.Empty).ToHashSet();
bool allRunningGotSucceeded = validRunningIds.All(id => succeededIds.Contains(id));
return noRunningFailures && allRunningGotSucceeded;
});
}
// ── Property 14: InspectionModuleNode 含 Pipeline 时传入快照 ─────────
// Feature: cnc-run-execution, Property 14: InspectionModuleNode 含 Pipeline 时传入快照
// Validates: Requirements 4.3
[Property(MaxTest = 100)]
public Property InspectionModuleNode_WithPipeline_SnapshotPassedToStore()
{
var gen =
from pipelineName in ArbMap.Default.GeneratorFor()
let pipeline = new PipelineModel { Name = pipelineName.Get }
let node = new InspectionModuleNode(Guid.NewGuid(), 0, "InspectNode", pipeline)
let program = new CncProgram(
Guid.NewGuid(), "TestProgram",
DateTime.UtcNow, DateTime.UtcNow,
new List { node }.AsReadOnly())
select (program, node, pipelineName.Get);
return Prop.ForAll(
gen.ToArbitrary(),
tuple =>
{
var (program, node, expectedPipelineName) = tuple;
var (service, mockStore, _) = CreateService();
PipelineExecutionSnapshot capturedSnapshot = null;
mockStore.Setup(s => s.AppendNodeResultAsync(
It.IsAny(),
It.IsAny>(),
It.IsAny(),
It.IsAny>()))
.Callback,
PipelineExecutionSnapshot, IEnumerable>(
(_, __, snapshot, ___) => capturedSnapshot = snapshot)
.Returns(Task.CompletedTask);
service.ExecuteAsync(program, null, CancellationToken.None)
.GetAwaiter().GetResult();
return capturedSnapshot != null
&& capturedSnapshot.PipelineName == expectedPipelineName;
});
}
}
}