diff --git a/XplorePlane.Tests/Pipeline/PipelinePersistenceServiceTests.cs b/XplorePlane.Tests/Pipeline/PipelinePersistenceServiceTests.cs index b3d8416..f274e0b 100644 --- a/XplorePlane.Tests/Pipeline/PipelinePersistenceServiceTests.cs +++ b/XplorePlane.Tests/Pipeline/PipelinePersistenceServiceTests.cs @@ -192,8 +192,8 @@ namespace XplorePlane.Tests.Pipeline { var m1 = BuildModel("P1", "Blur"); var m2 = BuildModel("P2", "Sharpen"); - await _svc.SaveAsync(m1, Path.Combine(_tempDir, "p1.pipeline.json")); - await _svc.SaveAsync(m2, Path.Combine(_tempDir, "p2.pipeline.json")); + await _svc.SaveAsync(m1, Path.Combine(_tempDir, "p1.xpm")); + await _svc.SaveAsync(m2, Path.Combine(_tempDir, "p2.xpm")); var result = await _svc.LoadAllAsync(_tempDir); diff --git a/XplorePlane.Tests/Services/CncExecutionServiceTests.cs b/XplorePlane.Tests/Services/CncExecutionServiceTests.cs new file mode 100644 index 0000000..1929964 --- /dev/null +++ b/XplorePlane.Tests/Services/CncExecutionServiceTests.cs @@ -0,0 +1,714 @@ +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; + }); + } + } +} diff --git a/XplorePlane.Tests/Services/CncNodeViewModelTests.cs b/XplorePlane.Tests/Services/CncNodeViewModelTests.cs new file mode 100644 index 0000000..c50a5ab --- /dev/null +++ b/XplorePlane.Tests/Services/CncNodeViewModelTests.cs @@ -0,0 +1,54 @@ +// Feature: cnc-run-execution, Property 11: 节点执行状态转换正确性 +// Validates: Requirements 6.1, 6.2 + +using System; +using FsCheck; +using FsCheck.Fluent; +using FsCheck.Xunit; +using XplorePlane.Models; +using XplorePlane.ViewModels.Cnc; + +namespace XplorePlane.Tests.Services +{ + public class CncNodeViewModelTests + { + // Feature: cnc-run-execution, Property 11: 节点执行状态转换正确性 + // Validates: Requirements 6.1, 6.2 + [Property(MaxTest = 100)] + public Property ExecutionState_DerivedBoolProperties_AreConsistent() + { + var gen = + from xm in ArbMap.Default.GeneratorFor() + select new ReferencePointNode( + Guid.NewGuid(), 0, "TestNode", + xm, 0, 0, 0, 0, 0, false, 0, 0); + + return Prop.ForAll( + gen.ToArbitrary(), + (ReferencePointNode node) => + { + var vm = new CncNodeViewModel(node, (_, __) => { }); + + // Running state + vm.ExecutionState = NodeExecutionState.Running; + bool runningOk = vm.IsRunningNode == true + && vm.IsSucceededNode == false + && vm.IsFailedNode == false; + + // Succeeded state + vm.ExecutionState = NodeExecutionState.Succeeded; + bool succeededOk = vm.IsRunningNode == false + && vm.IsSucceededNode == true + && vm.IsFailedNode == false; + + // Idle state + vm.ExecutionState = NodeExecutionState.Idle; + bool idleOk = vm.IsRunningNode == false + && vm.IsSucceededNode == false + && vm.IsFailedNode == false; + + return runningOk && succeededOk && idleOk; + }); + } + } +} diff --git a/XplorePlane.Tests/ViewModels/CncEditorViewModelTests.cs b/XplorePlane.Tests/ViewModels/CncEditorViewModelTests.cs new file mode 100644 index 0000000..af6c14d --- /dev/null +++ b/XplorePlane.Tests/ViewModels/CncEditorViewModelTests.cs @@ -0,0 +1,232 @@ +// Feature: cnc-run-execution +// Properties 1, 2, 12: CncEditorViewModel execution control + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FsCheck; +using FsCheck.Fluent; +using FsCheck.Xunit; +using Moq; +using Prism.Events; +using XP.Common.Logging.Interfaces; +using XplorePlane.Models; +using XplorePlane.Services.AppState; +using XplorePlane.Services.Cnc; +using XplorePlane.ViewModels.Cnc; +using Xunit; + +namespace XplorePlane.Tests.ViewModels +{ + public class CncEditorViewModelTests + { + // ── Helpers ────────────────────────────────────────────────────────── + + private static CncEditorViewModel CreateVm( + Mock mockExecSvc = null, + CncProgram initialProgram = null) + { + var mockCncProgramSvc = new Mock(); + var mockAppState = new Mock(); + var mockLogger = new Mock(); + mockLogger.Setup(l => l.ForModule()).Returns(mockLogger.Object); + + mockExecSvc ??= new Mock(); + + // Setup CreateProgram so ExecuteNewProgram works + mockCncProgramSvc + .Setup(s => s.CreateProgram(It.IsAny())) + .Returns((string name) => new CncProgram( + Guid.NewGuid(), name, + DateTime.UtcNow, DateTime.UtcNow, + new List + { + new ReferencePointNode(Guid.NewGuid(), 0, "参考点_0", 0, 0, 0, 0, 0, 0, false, 0, 0) + }.AsReadOnly())); + + var vm = new CncEditorViewModel( + mockCncProgramSvc.Object, + mockAppState.Object, + new EventAggregator(), + mockLogger.Object, + mockExecSvc.Object); + + if (initialProgram != null) + { + // Use reflection to set _currentProgram and refresh nodes + var field = typeof(CncEditorViewModel) + .GetField("_currentProgram", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + field?.SetValue(vm, initialProgram); + + var refresh = typeof(CncEditorViewModel) + .GetMethod("RefreshNodes", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + refresh?.Invoke(vm, null); + } + + return vm; + } + + private static CncProgram MakeProgram(int nodeCount = 2) + { + var nodes = Enumerable.Range(0, nodeCount) + .Select(i => (CncNode)new ReferencePointNode(Guid.NewGuid(), i, $"Node_{i}", 0, 0, 0, 0, 0, 0, false, 0, 0)) + .ToList() + .AsReadOnly(); + return new CncProgram(Guid.NewGuid(), "TestProgram", DateTime.UtcNow, DateTime.UtcNow, nodes); + } + + // ── Property 1: 运行/停止按钮状态互斥 ──────────────────────────────── + + // Feature: cnc-run-execution, Property 1: 运行/停止按钮状态互斥 + // Validates: Requirements 1.1, 1.3, 1.4 + [Property(MaxTest = 100)] + public Property RunStop_Commands_AreMutuallyExclusive() + { + var gen = + from nodeCount in Gen.Choose(1, 8) + select MakeProgram(nodeCount); + + return Prop.ForAll(gen.ToArbitrary(), program => + { + var tcs = new TaskCompletionSource(); + var mockExecSvc = new Mock(); + mockExecSvc + .Setup(s => s.ExecuteAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns(tcs.Task); + + var vm = CreateVm(mockExecSvc, program); + + // Before running: Run enabled, Stop disabled + bool beforeRunOk = vm.RunCncCommand.CanExecute() + && !vm.StopCncCommand.CanExecute(); + + // Start execution (fire-and-forget, task is blocked by tcs) + _ = Task.Run(() => vm.RunCncCommand.Execute()); + // Give the async method a moment to set IsRunning = true + SpinWait.SpinUntil(() => vm.IsRunning, TimeSpan.FromMilliseconds(500)); + + bool duringRunOk = !vm.RunCncCommand.CanExecute() + && vm.StopCncCommand.CanExecute(); + + // Complete execution + tcs.SetResult(true); + SpinWait.SpinUntil(() => !vm.IsRunning, TimeSpan.FromMilliseconds(500)); + + bool afterRunOk = vm.RunCncCommand.CanExecute() + && !vm.StopCncCommand.CanExecute(); + + return beforeRunOk && duringRunOk && afterRunOk; + }); + } + + // ── Property 2: 执行完成后状态重置 ─────────────────────────────────── + + // Feature: cnc-run-execution, Property 2: 执行完成后状态重置 + // Validates: Requirements 1.7, 6.5 + [Property(MaxTest = 100)] + public Property AfterExecution_IsRunningFalse_AllNodesIdle() + { + var gen = + from nodeCount in Gen.Choose(1, 6) + from cancelled in ArbMap.Default.GeneratorFor() + select (nodeCount, cancelled); + + return Prop.ForAll(gen.ToArbitrary(), tuple => + { + var (nodeCount, cancelled) = tuple; + var program = MakeProgram(nodeCount); + + var tcs = new TaskCompletionSource(); + var mockExecSvc = new Mock(); + mockExecSvc + .Setup(s => s.ExecuteAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns(tcs.Task); + + var vm = CreateVm(mockExecSvc, program); + + // Start execution + _ = Task.Run(() => vm.RunCncCommand.Execute()); + SpinWait.SpinUntil(() => vm.IsRunning, TimeSpan.FromMilliseconds(500)); + + // Simulate some nodes going Running + foreach (var node in vm.Nodes) + node.ExecutionState = NodeExecutionState.Running; + + // Complete or cancel + if (cancelled) + tcs.SetCanceled(); + else + tcs.SetResult(true); + + SpinWait.SpinUntil(() => !vm.IsRunning, TimeSpan.FromMilliseconds(500)); + + bool isRunningFalse = !vm.IsRunning; + bool allIdle = vm.Nodes.All(n => n.ExecutionState == NodeExecutionState.Idle); + + return isRunningFalse && allIdle; + }); + } + + // ── Property 12: 执行中编辑命令全部禁用 ────────────────────────────── + + // Feature: cnc-run-execution, Property 12: 执行中编辑命令全部禁用 + // Validates: Requirements 6.7 + [Property(MaxTest = 100)] + public Property WhileRunning_AllEditCommands_AreDisabled() + { + var gen = + from nodeCount in Gen.Choose(2, 8) + select MakeProgram(nodeCount); + + return Prop.ForAll(gen.ToArbitrary(), program => + { + var tcs = new TaskCompletionSource(); + var mockExecSvc = new Mock(); + mockExecSvc + .Setup(s => s.ExecuteAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns(tcs.Task); + + var vm = CreateVm(mockExecSvc, program); + + // Start execution + _ = Task.Run(() => vm.RunCncCommand.Execute()); + SpinWait.SpinUntil(() => vm.IsRunning, TimeSpan.FromMilliseconds(500)); + + // All insert/delete/move commands must be disabled + var editCommands = new[] + { + vm.InsertReferencePointCommand.CanExecute(), + vm.InsertSaveNodeWithImageCommand.CanExecute(), + vm.InsertSaveNodeCommand.CanExecute(), + vm.InsertSavePositionCommand.CanExecute(), + vm.InsertInspectionModuleCommand.CanExecute(), + vm.InsertInspectionMarkerCommand.CanExecute(), + vm.InsertPauseDialogCommand.CanExecute(), + vm.InsertWaitDelayCommand.CanExecute(), + vm.InsertCompleteProgramCommand.CanExecute(), + vm.DeleteNodeCommand.CanExecute(), + }; + + bool allDisabled = editCommands.All(canExec => !canExec); + + // Cleanup + tcs.SetResult(true); + SpinWait.SpinUntil(() => !vm.IsRunning, TimeSpan.FromMilliseconds(500)); + + return allDisabled; + }); + } + } +} diff --git a/XplorePlane.Tests/ViewModels/CncNodeViewModelTests.cs b/XplorePlane.Tests/ViewModels/CncNodeViewModelTests.cs new file mode 100644 index 0000000..67eb000 --- /dev/null +++ b/XplorePlane.Tests/ViewModels/CncNodeViewModelTests.cs @@ -0,0 +1,63 @@ +// Feature: cnc-run-execution, Property 11: 节点执行状态转换正确性 +// Validates: Requirements 6.1, 6.2 + +using System; +using FsCheck; +using FsCheck.Fluent; +using FsCheck.Xunit; +using XplorePlane.Models; +using XplorePlane.ViewModels.Cnc; + +namespace XplorePlane.Tests.ViewModels +{ + public class CncNodeViewModelTests + { + // ── Property 11: 节点执行状态转换正确性 ────────────────────────────── + + // Feature: cnc-run-execution, Property 11: 节点执行状态转换正确性 + // Validates: Requirements 6.1, 6.2 + [Property(MaxTest = 100)] + public Property ExecutionState_TransitionsProduceCorrectBoolProperties() + { + var gen = + from xm in ArbMap.Default.GeneratorFor() + from ym in ArbMap.Default.GeneratorFor() + select new ReferencePointNode( + Guid.NewGuid(), 0, "TestNode", + xm, ym, 0, 0, 0, 0, false, 0, 0); + + return Prop.ForAll( + gen.ToArbitrary(), + node => + { + var vm = new CncNodeViewModel(node, (vm2, n) => { }); + + // Running + vm.ExecutionState = NodeExecutionState.Running; + bool runningOk = vm.IsRunningNode == true + && vm.IsSucceededNode == false + && vm.IsFailedNode == false; + + // Succeeded + vm.ExecutionState = NodeExecutionState.Succeeded; + bool succeededOk = vm.IsRunningNode == false + && vm.IsSucceededNode == true + && vm.IsFailedNode == false; + + // Failed + vm.ExecutionState = NodeExecutionState.Failed; + bool failedOk = vm.IsRunningNode == false + && vm.IsSucceededNode == false + && vm.IsFailedNode == true; + + // Idle + vm.ExecutionState = NodeExecutionState.Idle; + bool idleOk = vm.IsRunningNode == false + && vm.IsSucceededNode == false + && vm.IsFailedNode == false; + + return runningOk && succeededOk && failedOk && idleOk; + }); + } + } +} diff --git a/XplorePlane/App.xaml.cs b/XplorePlane/App.xaml.cs index b818096..5d2912e 100644 --- a/XplorePlane/App.xaml.cs +++ b/XplorePlane/App.xaml.cs @@ -419,6 +419,7 @@ namespace XplorePlane containerRegistry.RegisterSingleton(); containerRegistry.RegisterSingleton(); containerRegistry.RegisterSingleton(); + containerRegistry.RegisterSingleton(); // ── 主界面实时图像 / 探测器双队列服务(单例)── containerRegistry.RegisterSingleton(); diff --git a/XplorePlane/Controls/CncExecutionScrollBehavior.cs b/XplorePlane/Controls/CncExecutionScrollBehavior.cs new file mode 100644 index 0000000..0892e7e --- /dev/null +++ b/XplorePlane/Controls/CncExecutionScrollBehavior.cs @@ -0,0 +1,34 @@ +using System.Windows; +using System.Windows.Controls; + +namespace XplorePlane.Controls +{ + /// + /// Attached behavior that calls BringIntoView() on a TreeViewItem + /// whenever the AutoScroll property transitions to true. + /// Bind to IsRunningNode to auto-scroll the executing node into view. + /// + public static class CncExecutionScrollBehavior + { + public static readonly DependencyProperty AutoScrollProperty = + DependencyProperty.RegisterAttached( + "AutoScroll", + typeof(bool), + typeof(CncExecutionScrollBehavior), + new PropertyMetadata(false, OnAutoScrollChanged)); + + public static bool GetAutoScroll(DependencyObject obj) + => (bool)obj.GetValue(AutoScrollProperty); + + public static void SetAutoScroll(DependencyObject obj, bool value) + => obj.SetValue(AutoScrollProperty, value); + + private static void OnAutoScrollChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (e.NewValue is true && d is TreeViewItem item) + { + item.BringIntoView(); + } + } + } +} diff --git a/XplorePlane/Models/CncModels.cs b/XplorePlane/Models/CncModels.cs index 56c1014..3ab9979 100644 --- a/XplorePlane/Models/CncModels.cs +++ b/XplorePlane/Models/CncModels.cs @@ -114,4 +114,19 @@ namespace XplorePlane.Models DateTime UpdatedAt, IReadOnlyList Nodes ); + + // ── 节点执行状态 | Node Execution State ────────────────────────── + + /// 节点执行状态枚举 | Node execution state enumeration + public enum NodeExecutionState + { + /// 未执行(默认)| Not yet executed (default) + Idle, + /// 正在执行 | Currently executing + Running, + /// 执行成功 | Execution succeeded + Succeeded, + /// 执行失败 | Execution failed + Failed + } } diff --git a/XplorePlane/Services/Cnc/CncExecutionService.cs b/XplorePlane/Services/Cnc/CncExecutionService.cs new file mode 100644 index 0000000..8efb8c2 --- /dev/null +++ b/XplorePlane/Services/Cnc/CncExecutionService.cs @@ -0,0 +1,164 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using XP.Common.Logging.Interfaces; +using XplorePlane.Models; +using XplorePlane.Services.InspectionResults; + +namespace XplorePlane.Services.Cnc +{ + /// + /// Executes a CNC program node-by-node, reporting progress and persisting inspection results. + /// + public class CncExecutionService : ICncExecutionService + { + private readonly IInspectionResultStore _store; + private readonly ILoggerService _logger; + + public CncExecutionService(IInspectionResultStore store, ILoggerService logger) + { + _store = store ?? throw new ArgumentNullException(nameof(store)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task ExecuteAsync(CncProgram program, IProgress progress, CancellationToken cancellationToken) + { + // Pre-cancellation check — do NOT call BeginRunAsync if already cancelled + if (cancellationToken.IsCancellationRequested) + return; + + int inspectionNodeCount = program.Nodes.OfType().Count(); + + Guid runId; + try + { + var runRecord = new InspectionRunRecord + { + ProgramName = program.Name, + NodeCount = inspectionNodeCount, + StartedAt = DateTime.UtcNow + }; + await _store.BeginRunAsync(runRecord); + runId = runRecord.RunId; + } + catch (Exception ex) + { + _logger.ForModule().Error(ex, "Failed to begin inspection run for program '{0}'", program.Name); + return; + } + + bool cancelled = false; + bool allSucceeded = true; + + foreach (var node in program.Nodes.OrderBy(n => n.Index)) + { + if (cancellationToken.IsCancellationRequested) + { + cancelled = true; + break; + } + + progress?.Report(new CncNodeExecutionProgress(node.Id, NodeExecutionState.Running)); + + bool nodeSucceeded = true; + + try + { + switch (node) + { + case WaitDelayNode waitNode: + try + { + await Task.Delay(waitNode.DelayMilliseconds, cancellationToken); + } + catch (OperationCanceledException) + { + cancelled = true; + } + break; + + case PauseDialogNode pauseNode: + await Application.Current.Dispatcher.InvokeAsync(() => + MessageBox.Show(pauseNode.DialogMessage, pauseNode.DialogTitle)); + if (cancellationToken.IsCancellationRequested) + cancelled = true; + break; + + case InspectionModuleNode inspectionNode: + try + { + var nodeResult = new InspectionNodeResult + { + RunId = runId, + NodeId = inspectionNode.Id, + NodeIndex = inspectionNode.Index, + NodeName = inspectionNode.Name + }; + + PipelineExecutionSnapshot pipelineSnapshot = inspectionNode.Pipeline != null + ? new PipelineExecutionSnapshot + { + RunId = runId, + NodeId = inspectionNode.Id, + PipelineName = inspectionNode.Pipeline.Name + } + : null; + + await _store.AppendNodeResultAsync(nodeResult, pipelineSnapshot: pipelineSnapshot); + } + catch (Exception ex) + { + _logger.ForModule().Error(ex, + "Failed to append node result for node '{0}' (Id={1})", inspectionNode.Name, inspectionNode.Id); + nodeSucceeded = false; + } + break; + + case CompleteProgramNode: + // Report Succeeded before terminating the loop + progress?.Report(new CncNodeExecutionProgress(node.Id, NodeExecutionState.Succeeded)); + goto endLoop; + + default: + // Unknown node types are treated as succeeded + break; + } + } + catch (Exception ex) + { + _logger.ForModule().Error(ex, + "Unexpected error executing node '{0}' (Id={1})", node.Name, node.Id); + nodeSucceeded = false; + } + + if (cancelled) + { + progress?.Report(new CncNodeExecutionProgress(node.Id, NodeExecutionState.Failed)); + break; + } + + var finalState = nodeSucceeded ? NodeExecutionState.Succeeded : NodeExecutionState.Failed; + progress?.Report(new CncNodeExecutionProgress(node.Id, finalState)); + + if (!nodeSucceeded) + allSucceeded = false; + } + + endLoop: + + bool? overallPass = cancelled ? null : (bool?)allSucceeded; + + try + { + await _store.CompleteRunAsync(runId, overallPass); + } + catch (Exception ex) + { + _logger.ForModule().Error(ex, + "Failed to complete inspection run '{0}'", runId); + } + } + } +} diff --git a/XplorePlane/Services/Cnc/ICncExecutionService.cs b/XplorePlane/Services/Cnc/ICncExecutionService.cs new file mode 100644 index 0000000..6a8328a --- /dev/null +++ b/XplorePlane/Services/Cnc/ICncExecutionService.cs @@ -0,0 +1,20 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using XplorePlane.Models; + +namespace XplorePlane.Services.Cnc +{ + /// + /// CNC program execution service interface. + /// + public interface ICncExecutionService + { + Task ExecuteAsync(CncProgram program, IProgress progress, CancellationToken cancellationToken); + } + + /// + /// Progress report for a single CNC node execution. + /// + public record CncNodeExecutionProgress(Guid NodeId, NodeExecutionState State); +} diff --git a/XplorePlane/ViewModels/Cnc/CncEditorViewModel.cs b/XplorePlane/ViewModels/Cnc/CncEditorViewModel.cs index 60a5c6e..a371375 100644 --- a/XplorePlane/ViewModels/Cnc/CncEditorViewModel.cs +++ b/XplorePlane/ViewModels/Cnc/CncEditorViewModel.cs @@ -9,6 +9,7 @@ using System.Globalization; using System.IO; using System.Linq; using System.Text; +using System.Threading; using System.Threading.Tasks; using XP.Common.Logging.Interfaces; using XplorePlane.Events; @@ -26,6 +27,7 @@ namespace XplorePlane.ViewModels.Cnc private readonly ICncProgramService _cncProgramService; private readonly IEventAggregator _eventAggregator; private readonly ILoggerService _logger; + private readonly ICncExecutionService _cncExecutionService; private CncProgram _currentProgram; private ObservableCollection _nodes; @@ -38,17 +40,21 @@ namespace XplorePlane.ViewModels.Cnc private Guid? _preferredSelectedNodeId; private Guid? _pendingInsertAnchorNodeId; private bool _pendingInsertAfterAnchor; + private CancellationTokenSource _cts; + private bool _isRunning; public CncEditorViewModel( ICncProgramService cncProgramService, IAppStateService appStateService, IEventAggregator eventAggregator, - ILoggerService logger) + ILoggerService logger, + ICncExecutionService cncExecutionService) { _cncProgramService = cncProgramService ?? throw new ArgumentNullException(nameof(cncProgramService)); ArgumentNullException.ThrowIfNull(appStateService); _eventAggregator = eventAggregator ?? throw new ArgumentNullException(nameof(eventAggregator)); _logger = (logger ?? throw new ArgumentNullException(nameof(logger))).ForModule(); + _cncExecutionService = cncExecutionService ?? throw new ArgumentNullException(nameof(cncExecutionService)); _nodes = new ObservableCollection(); _treeNodes = new ObservableCollection(); @@ -57,15 +63,15 @@ namespace XplorePlane.ViewModels.Cnc new CncProgramTreeRootViewModel(_programDisplayName, _treeNodes) }; - InsertReferencePointCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.ReferencePoint)); - InsertSaveNodeWithImageCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.SaveNodeWithImage)); - InsertSaveNodeCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.SaveNode)); - InsertSavePositionCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.SavePosition)); - InsertInspectionModuleCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.InspectionModule)); - InsertInspectionMarkerCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.InspectionMarker)); - InsertPauseDialogCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.PauseDialog)); - InsertWaitDelayCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.WaitDelay)); - InsertCompleteProgramCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.CompleteProgram)); + InsertReferencePointCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.ReferencePoint), () => !IsRunning); + InsertSaveNodeWithImageCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.SaveNodeWithImage), () => !IsRunning); + InsertSaveNodeCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.SaveNode), () => !IsRunning); + InsertSavePositionCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.SavePosition), () => !IsRunning); + InsertInspectionModuleCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.InspectionModule), () => !IsRunning); + InsertInspectionMarkerCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.InspectionMarker), () => !IsRunning); + InsertPauseDialogCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.PauseDialog), () => !IsRunning); + InsertWaitDelayCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.WaitDelay), () => !IsRunning); + InsertCompleteProgramCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.CompleteProgram), () => !IsRunning); DeleteNodeCommand = new DelegateCommand(ExecuteDeleteNode, CanExecuteDeleteNode) .ObservesProperty(() => SelectedNode); @@ -79,6 +85,9 @@ namespace XplorePlane.ViewModels.Cnc NewProgramCommand = new DelegateCommand(ExecuteNewProgram); ExportCsvCommand = new DelegateCommand(ExecuteExportCsv); + RunCncCommand = new DelegateCommand(async () => await ExecuteRunAsync(), CanExecuteRun); + StopCncCommand = new DelegateCommand(ExecuteStop, CanExecuteStop); + _logger.Info("CncEditorViewModel initialized"); } @@ -136,6 +145,20 @@ namespace XplorePlane.ViewModels.Cnc } } + public bool IsRunning + { + get => _isRunning; + private set + { + if (SetProperty(ref _isRunning, value)) + { + RunCncCommand.RaiseCanExecuteChanged(); + StopCncCommand.RaiseCanExecuteChanged(); + RaiseEditCommandsCanExecuteChanged(); + } + } + } + public DelegateCommand InsertReferencePointCommand { get; } public DelegateCommand InsertSaveNodeWithImageCommand { get; } public DelegateCommand InsertSaveNodeCommand { get; } @@ -154,9 +177,14 @@ namespace XplorePlane.ViewModels.Cnc public DelegateCommand LoadProgramCommand { get; } public DelegateCommand NewProgramCommand { get; } public DelegateCommand ExportCsvCommand { get; } + public DelegateCommand RunCncCommand { get; } + public DelegateCommand StopCncCommand { get; } private void ExecuteInsertNode(CncNodeType nodeType) { + if (IsRunning) + return; + if (_currentProgram == null) { ExecuteNewProgram(); @@ -213,14 +241,15 @@ namespace XplorePlane.ViewModels.Cnc private bool CanExecuteDeleteNode() { - return SelectedNode != null + return !IsRunning + && SelectedNode != null && _currentProgram != null && _currentProgram.Nodes.Count > 1; } private void ExecuteMoveNodeUp(CncNodeViewModel nodeVm) { - if (_currentProgram == null || nodeVm == null || nodeVm.Index <= 0) + if (IsRunning || _currentProgram == null || nodeVm == null || nodeVm.Index <= 0) return; try @@ -244,7 +273,7 @@ namespace XplorePlane.ViewModels.Cnc private void ExecuteMoveNodeDown(CncNodeViewModel nodeVm) { - if (_currentProgram == null || nodeVm == null || nodeVm.Index >= _currentProgram.Nodes.Count - 1) + if (IsRunning || _currentProgram == null || nodeVm == null || nodeVm.Index >= _currentProgram.Nodes.Count - 1) return; try @@ -396,6 +425,68 @@ namespace XplorePlane.ViewModels.Cnc return value; } + private bool CanExecuteRun() + => !IsRunning && _currentProgram?.Nodes?.Count > 0; + + private bool CanExecuteStop() + => IsRunning; + + private async Task ExecuteRunAsync() + { + _cts = new CancellationTokenSource(); + IsRunning = true; + try + { + var progress = new Progress(OnExecutionProgress); + await _cncExecutionService.ExecuteAsync(_currentProgram, progress, _cts.Token); + } + catch (Exception ex) + { + _logger.Error(ex, "CNC execution failed"); + } + finally + { + IsRunning = false; + ResetAllNodeStates(); + _cts?.Dispose(); + _cts = null; + } + } + + private void ExecuteStop() + { + _cts?.Cancel(); + } + + private void OnExecutionProgress(CncNodeExecutionProgress progress) + { + var nodeVm = Nodes.FirstOrDefault(n => n.Id == progress.NodeId); + if (nodeVm != null) + nodeVm.ExecutionState = progress.State; + } + + private void ResetAllNodeStates() + { + foreach (var node in Nodes) + node.ExecutionState = NodeExecutionState.Idle; + } + + private void RaiseEditCommandsCanExecuteChanged() + { + InsertReferencePointCommand.RaiseCanExecuteChanged(); + InsertSaveNodeWithImageCommand.RaiseCanExecuteChanged(); + InsertSaveNodeCommand.RaiseCanExecuteChanged(); + InsertSavePositionCommand.RaiseCanExecuteChanged(); + InsertInspectionModuleCommand.RaiseCanExecuteChanged(); + InsertInspectionMarkerCommand.RaiseCanExecuteChanged(); + InsertPauseDialogCommand.RaiseCanExecuteChanged(); + InsertWaitDelayCommand.RaiseCanExecuteChanged(); + InsertCompleteProgramCommand.RaiseCanExecuteChanged(); + DeleteNodeCommand.RaiseCanExecuteChanged(); + MoveNodeUpCommand.RaiseCanExecuteChanged(); + MoveNodeDownCommand.RaiseCanExecuteChanged(); + } + private void OnProgramEdited() { IsModified = true; diff --git a/XplorePlane/ViewModels/Cnc/CncNodeViewModel.cs b/XplorePlane/ViewModels/Cnc/CncNodeViewModel.cs index 0fed114..fef97b5 100644 --- a/XplorePlane/ViewModels/Cnc/CncNodeViewModel.cs +++ b/XplorePlane/ViewModels/Cnc/CncNodeViewModel.cs @@ -14,6 +14,7 @@ namespace XplorePlane.ViewModels.Cnc private CncNode _model; private string _icon; private bool _isExpanded = true; + private NodeExecutionState _executionState = NodeExecutionState.Idle; public CncNodeViewModel(CncNode model, Action modelChangedCallback) { @@ -57,6 +58,24 @@ namespace XplorePlane.ViewModels.Cnc public bool HasChildren => Children.Count > 0; + public NodeExecutionState ExecutionState + { + get => _executionState; + set + { + if (SetProperty(ref _executionState, value)) + { + RaisePropertyChanged(nameof(IsRunningNode)); + RaisePropertyChanged(nameof(IsSucceededNode)); + RaisePropertyChanged(nameof(IsFailedNode)); + } + } + } + + public bool IsRunningNode => ExecutionState == NodeExecutionState.Running; + public bool IsSucceededNode => ExecutionState == NodeExecutionState.Succeeded; + public bool IsFailedNode => ExecutionState == NodeExecutionState.Failed; + public bool IsReferencePoint => _model is ReferencePointNode; public bool IsSaveNode => _model is SaveNodeNode; public bool IsSaveNodeWithImage => _model is SaveNodeWithImageNode; @@ -540,6 +559,10 @@ namespace XplorePlane.ViewModels.Cnc RaisePropertyChanged(nameof(DialogTitle)); RaisePropertyChanged(nameof(DialogMessage)); RaisePropertyChanged(nameof(DelayMilliseconds)); + RaisePropertyChanged(nameof(ExecutionState)); + RaisePropertyChanged(nameof(IsRunningNode)); + RaisePropertyChanged(nameof(IsSucceededNode)); + RaisePropertyChanged(nameof(IsFailedNode)); } private enum MotionAxis diff --git a/XplorePlane/ViewModels/Main/MainViewModel.cs b/XplorePlane/ViewModels/Main/MainViewModel.cs index f3db8d7..3b52f4f 100644 --- a/XplorePlane/ViewModels/Main/MainViewModel.cs +++ b/XplorePlane/ViewModels/Main/MainViewModel.cs @@ -87,6 +87,8 @@ namespace XplorePlane.ViewModels InsertSaveNodeCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.InsertSaveNodeCommand.Execute())); InsertPauseDialogCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.InsertPauseDialogCommand.Execute())); InsertWaitDelayCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.InsertWaitDelayCommand.Execute())); + RunCncCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.RunCncCommand.Execute())); + StopCncCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.StopCncCommand.Execute())); PointDistanceMeasureCommand = new DelegateCommand(ExecutePointDistanceMeasure); PointLineDistanceMeasureCommand = new DelegateCommand(ExecutePointLineDistanceMeasure); @@ -148,6 +150,8 @@ namespace XplorePlane.ViewModels public DelegateCommand InsertSaveNodeCommand { get; } public DelegateCommand InsertPauseDialogCommand { get; } public DelegateCommand InsertWaitDelayCommand { get; } + public DelegateCommand RunCncCommand { get; } + public DelegateCommand StopCncCommand { get; } public DelegateCommand AxisResetCommand { get; } public DelegateCommand OpenDetectorConfigCommand { get; } diff --git a/XplorePlane/Views/Cnc/CncPageView.xaml b/XplorePlane/Views/Cnc/CncPageView.xaml index b320d06..6755537 100644 --- a/XplorePlane/Views/Cnc/CncPageView.xaml +++ b/XplorePlane/Views/Cnc/CncPageView.xaml @@ -6,6 +6,7 @@ xmlns:local="clr-namespace:XplorePlane.Views.Cnc" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:prism="http://prismlibrary.com/" + xmlns:behaviors="clr-namespace:XplorePlane.Controls" xmlns:views="clr-namespace:XplorePlane.Views" xmlns:vm="clr-namespace:XplorePlane.ViewModels.Cnc" d:DesignHeight="760" @@ -244,6 +245,7 @@ + + + + + + + + + + + + + + + -