CNC执行逻辑的开发,点击运行,停止

This commit is contained in:
zhengxuan.zhang
2026-04-27 16:18:47 +08:00
parent e24bfef3e6
commit 2a64d48b54
14 changed files with 1450 additions and 16 deletions
@@ -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);
@@ -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 ──────────────────────────────────────────────────────
/// <summary>
/// A synchronous IProgress&lt;T&gt; implementation that invokes the callback inline
/// (on the calling thread), avoiding async dispatch timing issues in tests.
/// </summary>
internal sealed class SynchronousProgress<T> : IProgress<T>
{
private readonly Action<T> _handler;
public SynchronousProgress(Action<T> handler) => _handler = handler;
public void Report(T value) => _handler(value);
}
// ── Generators ────────────────────────────────────────────────────────────
/// <summary>
/// FsCheck generators for CNC program models.
/// Avoids PauseDialogNode (requires Application.Current.Dispatcher).
/// </summary>
public static class CncProgramGenerators
{
private static Gen<string> NonEmptyStringGen =>
ArbMap.Default.GeneratorFor<NonEmptyString>().Select(s => s.Get);
private static Gen<PipelineModel> PipelineGen =>
from name in NonEmptyStringGen
select new PipelineModel { Name = name };
/// <summary>Generates a random CncNode (WaitDelayNode, InspectionModuleNode, CompleteProgramNode, or ReferencePointNode).</summary>
public static Gen<CncNode> CncNodeGen(int index) =>
Gen.Choose(0, 3).SelectMany(kind =>
{
switch (kind)
{
case 0:
// WaitDelayNode with 0ms delay so tests run fast
return Gen.Constant<CncNode>(
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<CncNode>(
new CompleteProgramNode(Guid.NewGuid(), index, $"Complete_{index}"));
default:
return Gen.Constant<CncNode>(
new ReferencePointNode(Guid.NewGuid(), index, $"Ref_{index}",
0, 0, 0, 0, 0, 0, false, 0, 0));
}
});
/// <summary>Generates a CncProgram with minNodes..maxNodes random nodes, unique ascending indices.</summary>
public static Arbitrary<CncProgram> 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<int>()
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();
}
/// <summary>Generates a CncProgram with at least minInspection InspectionModuleNodes (no CompleteProgramNode).</summary>
public static Arbitrary<CncProgram> 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<List<CncNode>> GenNodes(int count)
{
if (count == 0)
return Gen.Constant(new List<CncNode>());
Gen<List<CncNode>> acc = Gen.Constant(new List<CncNode>());
for (int i = 0; i < count; i++)
{
var idx = i;
acc = from list in acc
from node in CncNodeGen(idx)
select new List<CncNode>(list) { node };
}
return acc;
}
private static Gen<List<CncNode>> GenInspectionNodes(int count, int startIndex)
{
Gen<List<CncNode>> acc = Gen.Constant(new List<CncNode>());
for (int i = 0; i < count; i++)
{
var idx = startIndex + i;
acc = from list in acc
from pipeline in PipelineGen
select new List<CncNode>(list)
{
new InspectionModuleNode(Guid.NewGuid(), idx, $"Inspect_{idx}", pipeline)
};
}
return acc;
}
private static Gen<List<CncNode>> GenOtherNodes(int count, int startIndex)
{
Gen<List<CncNode>> acc = Gen.Constant(new List<CncNode>());
for (int i = 0; i < count; i++)
{
var idx = startIndex + i;
acc = from list in acc
select new List<CncNode>(list)
{
new ReferencePointNode(Guid.NewGuid(), idx, $"Ref_{idx}",
0, 0, 0, 0, 0, 0, false, 0, 0)
};
}
return acc;
}
private static List<CncNode> ShuffleByIndex(List<CncNode> 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<IInspectionResultStore> Store, Mock<ILoggerService> Logger)
CreateService()
{
var mockStore = new Mock<IInspectionResultStore>();
var mockLogger = new Mock<ILoggerService>();
mockLogger.Setup(l => l.ForModule<CncExecutionService>()).Returns(mockLogger.Object);
mockStore.Setup(s => s.BeginRunAsync(
It.IsAny<InspectionRunRecord>(),
It.IsAny<InspectionAssetWriteRequest>()))
.Returns(Task.CompletedTask);
mockStore.Setup(s => s.AppendNodeResultAsync(
It.IsAny<InspectionNodeResult>(),
It.IsAny<IEnumerable<InspectionMetricResult>>(),
It.IsAny<PipelineExecutionSnapshot>(),
It.IsAny<IEnumerable<InspectionAssetWriteRequest>>()))
.Returns(Task.CompletedTask);
mockStore.Setup(s => s.CompleteRunAsync(
It.IsAny<Guid>(),
It.IsAny<bool?>(),
It.IsAny<DateTime?>()))
.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<Guid>();
// Use SynchronousProgress to avoid async callback timing issues
var progress = new SynchronousProgress<CncNodeExecutionProgress>(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<Guid>();
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<NonEmptyString>()
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<Guid>();
var progress = new SynchronousProgress<CncNodeExecutionProgress>(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<List<CncNode>> BuildProgramWithCompleteAt(int total, int completePos)
{
Gen<List<CncNode>> acc = Gen.Constant(new List<CncNode>());
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<CncNode>(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<InspectionRunRecord>(),
It.IsAny<InspectionAssetWriteRequest>()))
.Callback<InspectionRunRecord, InspectionAssetWriteRequest>((r, _) => capturedRecord = r)
.Returns(Task.CompletedTask);
service.ExecuteAsync(program, null, CancellationToken.None)
.GetAwaiter().GetResult();
mockStore.Verify(s => s.BeginRunAsync(
It.IsAny<InspectionRunRecord>(),
It.IsAny<InspectionAssetWriteRequest>()), 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<InspectionNodeResult>(),
It.IsAny<IEnumerable<InspectionMetricResult>>(),
It.IsAny<PipelineExecutionSnapshot>(),
It.IsAny<IEnumerable<InspectionAssetWriteRequest>>()), 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<InspectionRunRecord>(),
It.IsAny<InspectionAssetWriteRequest>()))
.Callback<InspectionRunRecord, InspectionAssetWriteRequest>((r, _) => capturedRecord = r)
.Returns(Task.CompletedTask);
service.ExecuteAsync(program, null, CancellationToken.None)
.GetAwaiter().GetResult();
int expectedCount = program.Nodes.OfType<InspectionModuleNode>().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<Guid>(),
It.IsAny<bool?>(),
It.IsAny<DateTime?>()))
.Callback<Guid, bool?, DateTime?>((_, pass, __) =>
{
capturedOverallPass = pass;
callbackInvoked = true;
})
.Returns(Task.CompletedTask);
service.ExecuteAsync(program, null, CancellationToken.None)
.GetAwaiter().GetResult();
mockStore.Verify(s => s.CompleteRunAsync(
It.IsAny<Guid>(),
It.IsAny<bool?>(),
It.IsAny<DateTime?>()), 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<CncNode> { waitNode }.AsReadOnly());
bool? capturedOverallPass = default;
bool callbackInvoked = false;
mockStore.Setup(s => s.CompleteRunAsync(
It.IsAny<Guid>(),
It.IsAny<bool?>(),
It.IsAny<DateTime?>()))
.Callback<Guid, bool?, DateTime?>((_, 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<Guid>(),
It.IsAny<bool?>(),
It.IsAny<DateTime?>()), 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<InspectionNodeResult>(),
It.IsAny<IEnumerable<InspectionMetricResult>>(),
It.IsAny<PipelineExecutionSnapshot>(),
It.IsAny<IEnumerable<InspectionAssetWriteRequest>>()))
.ThrowsAsync(new Exception("Archive failure"));
var runningIds = new List<Guid>();
var progress = new SynchronousProgress<CncNodeExecutionProgress>(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<Guid>();
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<Guid>(),
It.IsAny<bool?>(),
It.IsAny<DateTime?>()), 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<Guid>(),
It.IsAny<bool?>(),
It.IsAny<DateTime?>()))
.Callback<Guid, bool?, DateTime?>((_, pass, __) => capturedOverallPass = pass)
.Returns(Task.CompletedTask);
var program = new CncProgram(
Guid.NewGuid(), "WaitProgram",
DateTime.UtcNow, DateTime.UtcNow,
new List<CncNode> { 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<Guid>();
var succeededIds = new HashSet<Guid>();
var progress = new SynchronousProgress<CncNodeExecutionProgress>(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<NonEmptyString>()
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<CncNode> { 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<InspectionNodeResult>(),
It.IsAny<IEnumerable<InspectionMetricResult>>(),
It.IsAny<PipelineExecutionSnapshot>(),
It.IsAny<IEnumerable<InspectionAssetWriteRequest>>()))
.Callback<InspectionNodeResult, IEnumerable<InspectionMetricResult>,
PipelineExecutionSnapshot, IEnumerable<InspectionAssetWriteRequest>>(
(_, __, snapshot, ___) => capturedSnapshot = snapshot)
.Returns(Task.CompletedTask);
service.ExecuteAsync(program, null, CancellationToken.None)
.GetAwaiter().GetResult();
return capturedSnapshot != null
&& capturedSnapshot.PipelineName == expectedPipelineName;
});
}
}
}
@@ -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<double>()
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;
});
}
}
}
@@ -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<ICncExecutionService> mockExecSvc = null,
CncProgram initialProgram = null)
{
var mockCncProgramSvc = new Mock<ICncProgramService>();
var mockAppState = new Mock<IAppStateService>();
var mockLogger = new Mock<ILoggerService>();
mockLogger.Setup(l => l.ForModule<CncEditorViewModel>()).Returns(mockLogger.Object);
mockExecSvc ??= new Mock<ICncExecutionService>();
// Setup CreateProgram so ExecuteNewProgram works
mockCncProgramSvc
.Setup(s => s.CreateProgram(It.IsAny<string>()))
.Returns((string name) => new CncProgram(
Guid.NewGuid(), name,
DateTime.UtcNow, DateTime.UtcNow,
new List<CncNode>
{
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<bool>();
var mockExecSvc = new Mock<ICncExecutionService>();
mockExecSvc
.Setup(s => s.ExecuteAsync(
It.IsAny<CncProgram>(),
It.IsAny<IProgress<CncNodeExecutionProgress>>(),
It.IsAny<CancellationToken>()))
.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<bool>()
select (nodeCount, cancelled);
return Prop.ForAll(gen.ToArbitrary(), tuple =>
{
var (nodeCount, cancelled) = tuple;
var program = MakeProgram(nodeCount);
var tcs = new TaskCompletionSource<bool>();
var mockExecSvc = new Mock<ICncExecutionService>();
mockExecSvc
.Setup(s => s.ExecuteAsync(
It.IsAny<CncProgram>(),
It.IsAny<IProgress<CncNodeExecutionProgress>>(),
It.IsAny<CancellationToken>()))
.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<bool>();
var mockExecSvc = new Mock<ICncExecutionService>();
mockExecSvc
.Setup(s => s.ExecuteAsync(
It.IsAny<CncProgram>(),
It.IsAny<IProgress<CncNodeExecutionProgress>>(),
It.IsAny<CancellationToken>()))
.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;
});
}
}
}
@@ -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<double>()
from ym in ArbMap.Default.GeneratorFor<double>()
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;
});
}
}
}
+1
View File
@@ -419,6 +419,7 @@ namespace XplorePlane
containerRegistry.RegisterSingleton<IMatrixService, MatrixService>();
containerRegistry.RegisterSingleton<IMeasurementDataService, MeasurementDataService>();
containerRegistry.RegisterSingleton<IInspectionResultStore, InspectionResultStore>();
containerRegistry.RegisterSingleton<ICncExecutionService, CncExecutionService>();
// ── 主界面实时图像 / 探测器双队列服务(单例)──
containerRegistry.RegisterSingleton<IMainViewportService, MainViewportService>();
@@ -0,0 +1,34 @@
using System.Windows;
using System.Windows.Controls;
namespace XplorePlane.Controls
{
/// <summary>
/// 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.
/// </summary>
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();
}
}
}
}
+15
View File
@@ -114,4 +114,19 @@ namespace XplorePlane.Models
DateTime UpdatedAt,
IReadOnlyList<CncNode> Nodes
);
// ── 节点执行状态 | Node Execution State ──────────────────────────
/// <summary>节点执行状态枚举 | Node execution state enumeration</summary>
public enum NodeExecutionState
{
/// <summary>未执行(默认)| Not yet executed (default)</summary>
Idle,
/// <summary>正在执行 | Currently executing</summary>
Running,
/// <summary>执行成功 | Execution succeeded</summary>
Succeeded,
/// <summary>执行失败 | Execution failed</summary>
Failed
}
}
@@ -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
{
/// <summary>
/// Executes a CNC program node-by-node, reporting progress and persisting inspection results.
/// </summary>
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<CncNodeExecutionProgress> progress, CancellationToken cancellationToken)
{
// Pre-cancellation check — do NOT call BeginRunAsync if already cancelled
if (cancellationToken.IsCancellationRequested)
return;
int inspectionNodeCount = program.Nodes.OfType<InspectionModuleNode>().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<CncExecutionService>().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<CncExecutionService>().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<CncExecutionService>().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<CncExecutionService>().Error(ex,
"Failed to complete inspection run '{0}'", runId);
}
}
}
}
@@ -0,0 +1,20 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using XplorePlane.Models;
namespace XplorePlane.Services.Cnc
{
/// <summary>
/// CNC program execution service interface.
/// </summary>
public interface ICncExecutionService
{
Task ExecuteAsync(CncProgram program, IProgress<CncNodeExecutionProgress> progress, CancellationToken cancellationToken);
}
/// <summary>
/// Progress report for a single CNC node execution.
/// </summary>
public record CncNodeExecutionProgress(Guid NodeId, NodeExecutionState State);
}
+104 -13
View File
@@ -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<CncNodeViewModel> _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<CncEditorViewModel>();
_cncExecutionService = cncExecutionService ?? throw new ArgumentNullException(nameof(cncExecutionService));
_nodes = new ObservableCollection<CncNodeViewModel>();
_treeNodes = new ObservableCollection<CncNodeViewModel>();
@@ -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<CncNodeExecutionProgress>(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;
@@ -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<CncNodeViewModel, CncNode> 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
@@ -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; }
+20 -1
View File
@@ -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 @@
</Border>
<TextBlock
x:Name="NodeNameText"
Grid.Column="2"
Margin="3,0,0,0"
VerticalAlignment="Center"
@@ -286,11 +288,28 @@
<Setter TargetName="NodeCard" Property="Background" Value="#DCEEFF" />
<Setter TargetName="NodeCard" Property="BorderBrush" Value="#71A9DB" />
</DataTrigger>
<DataTrigger Binding="{Binding ExecutionState}" Value="Running">
<Setter TargetName="NodeCard" Property="Background" Value="#FF1E6FD9" />
<Setter TargetName="NodeCard" Property="BorderBrush" Value="#FF1E6FD9" />
<Setter TargetName="NodeNameText" Property="Foreground" Value="White" />
</DataTrigger>
<DataTrigger Binding="{Binding ExecutionState}" Value="Succeeded">
<Setter TargetName="NodeCard" Property="Background" Value="#FF2E7D32" />
<Setter TargetName="NodeCard" Property="BorderBrush" Value="#FF2E7D32" />
<Setter TargetName="NodeNameText" Property="Foreground" Value="White" />
</DataTrigger>
<DataTrigger Binding="{Binding ExecutionState}" Value="Failed">
<Setter TargetName="NodeCard" Property="Background" Value="#FFC62828" />
<Setter TargetName="NodeCard" Property="BorderBrush" Value="#FFC62828" />
<Setter TargetName="NodeNameText" Property="Foreground" Value="White" />
</DataTrigger>
</DataTemplate.Triggers>
</HierarchicalDataTemplate>
</TreeView.Resources>
<TreeView.ItemContainerStyle>
<Style BasedOn="{StaticResource TreeItemStyle}" TargetType="TreeViewItem" />
<Style BasedOn="{StaticResource TreeItemStyle}" TargetType="TreeViewItem">
<Setter Property="behaviors:CncExecutionScrollBehavior.AutoScroll" Value="{Binding IsRunningNode}" />
</Style>
</TreeView.ItemContainerStyle>
</TreeView>
</Grid>