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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+