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