Compare commits
9 Commits
6d5257bc95
...
0206da59b1
| Author | SHA1 | Date | |
|---|---|---|---|
| 0206da59b1 | |||
| 130f39db49 | |||
| 80fddc45dd | |||
| 514eace979 | |||
| 89759cf511 | |||
| 0d6a4bd22f | |||
| 2969ada965 | |||
| 06714f819f | |||
| 2a64d48b54 |
@@ -62,3 +62,4 @@ ExternalLibraries/Models/
|
|||||||
# 排除测试目录
|
# 排除测试目录
|
||||||
XplorePlane/Tests/
|
XplorePlane/Tests/
|
||||||
ExternalLibraries/Telerik/
|
ExternalLibraries/Telerik/
|
||||||
|
build_out.txt
|
||||||
|
|||||||
@@ -192,8 +192,8 @@ namespace XplorePlane.Tests.Pipeline
|
|||||||
{
|
{
|
||||||
var m1 = BuildModel("P1", "Blur");
|
var m1 = BuildModel("P1", "Blur");
|
||||||
var m2 = BuildModel("P2", "Sharpen");
|
var m2 = BuildModel("P2", "Sharpen");
|
||||||
await _svc.SaveAsync(m1, Path.Combine(_tempDir, "p1.pipeline.json"));
|
await _svc.SaveAsync(m1, Path.Combine(_tempDir, "p1.xpm"));
|
||||||
await _svc.SaveAsync(m2, Path.Combine(_tempDir, "p2.pipeline.json"));
|
await _svc.SaveAsync(m2, Path.Combine(_tempDir, "p2.xpm"));
|
||||||
|
|
||||||
var result = await _svc.LoadAllAsync(_tempDir);
|
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<T> 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -404,6 +404,7 @@ namespace XplorePlane
|
|||||||
containerRegistry.RegisterSingleton<IMatrixService, MatrixService>();
|
containerRegistry.RegisterSingleton<IMatrixService, MatrixService>();
|
||||||
containerRegistry.RegisterSingleton<IMeasurementDataService, MeasurementDataService>();
|
containerRegistry.RegisterSingleton<IMeasurementDataService, MeasurementDataService>();
|
||||||
containerRegistry.RegisterSingleton<IInspectionResultStore, InspectionResultStore>();
|
containerRegistry.RegisterSingleton<IInspectionResultStore, InspectionResultStore>();
|
||||||
|
containerRegistry.RegisterSingleton<ICncExecutionService, CncExecutionService>();
|
||||||
|
|
||||||
// ── 主界面实时图像 / 探测器双队列服务(单例)──
|
// ── 主界面实时图像 / 探测器双队列服务(单例)──
|
||||||
containerRegistry.RegisterSingleton<IMainViewportService, MainViewportService>();
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -114,4 +114,19 @@ namespace XplorePlane.Models
|
|||||||
DateTime UpdatedAt,
|
DateTime UpdatedAt,
|
||||||
IReadOnlyList<CncNode> Nodes
|
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,347 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using XP.Common.GeneralForm.Views;
|
||||||
|
using XP.Common.Logging.Interfaces;
|
||||||
|
using XplorePlane.Models;
|
||||||
|
using XplorePlane.Services.InspectionResults;
|
||||||
|
using XplorePlane.Services.MainViewport;
|
||||||
|
using XplorePlane.ViewModels;
|
||||||
|
|
||||||
|
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;
|
||||||
|
private readonly IMainViewportService _mainViewportService;
|
||||||
|
private readonly IPipelineExecutionService _pipelineExecutionService;
|
||||||
|
private readonly IImageProcessingService _imageProcessingService;
|
||||||
|
|
||||||
|
public CncExecutionService(
|
||||||
|
IInspectionResultStore store,
|
||||||
|
ILoggerService logger,
|
||||||
|
IMainViewportService mainViewportService,
|
||||||
|
IPipelineExecutionService pipelineExecutionService,
|
||||||
|
IImageProcessingService imageProcessingService)
|
||||||
|
{
|
||||||
|
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
_mainViewportService = mainViewportService;
|
||||||
|
_pipelineExecutionService = pipelineExecutionService;
|
||||||
|
_imageProcessingService = imageProcessingService;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
// 获取当前源图像(用于 run/source.bmp)
|
||||||
|
var sourceImage = _mainViewportService?.LatestManualImage as BitmapSource
|
||||||
|
?? _mainViewportService?.CurrentDisplayImage as BitmapSource;
|
||||||
|
|
||||||
|
Guid runId;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var runRecord = new InspectionRunRecord
|
||||||
|
{
|
||||||
|
ProgramName = program.Name,
|
||||||
|
NodeCount = inspectionNodeCount,
|
||||||
|
StartedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
InspectionAssetWriteRequest sourceAsset = null;
|
||||||
|
if (sourceImage != null)
|
||||||
|
{
|
||||||
|
sourceAsset = new InspectionAssetWriteRequest
|
||||||
|
{
|
||||||
|
AssetType = InspectionAssetType.RunSourceImage,
|
||||||
|
Content = EncodeBitmapToBmp(sourceImage),
|
||||||
|
FileFormat = "bmp",
|
||||||
|
Width = sourceImage.PixelWidth,
|
||||||
|
Height = sourceImage.PixelHeight
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await _store.BeginRunAsync(runRecord, sourceAsset);
|
||||||
|
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 ExecuteWaitDelayWithProgressAsync(waitNode, 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
|
||||||
|
{
|
||||||
|
await ExecuteInspectionNodeAsync(runId, inspectionNode, sourceImage, cancellationToken);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ExecuteInspectionNodeAsync(
|
||||||
|
Guid runId,
|
||||||
|
InspectionModuleNode inspectionNode,
|
||||||
|
BitmapSource sourceImage,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var nodeResult = new InspectionNodeResult
|
||||||
|
{
|
||||||
|
RunId = runId,
|
||||||
|
NodeId = inspectionNode.Id,
|
||||||
|
NodeIndex = inspectionNode.Index,
|
||||||
|
NodeName = inspectionNode.Name,
|
||||||
|
PipelineName = inspectionNode.Pipeline?.Name ?? string.Empty
|
||||||
|
};
|
||||||
|
|
||||||
|
PipelineExecutionSnapshot pipelineSnapshot = null;
|
||||||
|
if (inspectionNode.Pipeline != null)
|
||||||
|
{
|
||||||
|
var pipelineJson = JsonSerializer.Serialize(inspectionNode.Pipeline);
|
||||||
|
pipelineSnapshot = new PipelineExecutionSnapshot
|
||||||
|
{
|
||||||
|
RunId = runId,
|
||||||
|
NodeId = inspectionNode.Id,
|
||||||
|
PipelineName = inspectionNode.Pipeline.Name,
|
||||||
|
PipelineDefinitionJson = pipelineJson
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建资产列表
|
||||||
|
var assets = new System.Collections.Generic.List<InspectionAssetWriteRequest>();
|
||||||
|
|
||||||
|
// input.bmp — 当前源图像
|
||||||
|
if (sourceImage != null)
|
||||||
|
{
|
||||||
|
assets.Add(new InspectionAssetWriteRequest
|
||||||
|
{
|
||||||
|
AssetType = InspectionAssetType.NodeInputImage,
|
||||||
|
Content = EncodeBitmapToBmp(sourceImage),
|
||||||
|
FileFormat = "bmp",
|
||||||
|
Width = sourceImage.PixelWidth,
|
||||||
|
Height = sourceImage.PixelHeight
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// result_overlay.bmp — 执行流水线后的结果图像
|
||||||
|
if (_pipelineExecutionService != null && inspectionNode.Pipeline?.Nodes?.Count > 0 && sourceImage != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pipelineNodes = BuildPipelineNodeViewModels(inspectionNode.Pipeline);
|
||||||
|
var resultImage = await _pipelineExecutionService.ExecutePipelineAsync(
|
||||||
|
pipelineNodes, sourceImage, null, cancellationToken);
|
||||||
|
|
||||||
|
if (resultImage != null)
|
||||||
|
{
|
||||||
|
assets.Add(new InspectionAssetWriteRequest
|
||||||
|
{
|
||||||
|
AssetType = InspectionAssetType.NodeResultImage,
|
||||||
|
Content = EncodeBitmapToBmp(resultImage),
|
||||||
|
FileFormat = "bmp",
|
||||||
|
Width = resultImage.PixelWidth,
|
||||||
|
Height = resultImage.PixelHeight
|
||||||
|
});
|
||||||
|
nodeResult.Status = InspectionNodeStatus.Succeeded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.ForModule<CncExecutionService>().Warn(
|
||||||
|
"Pipeline execution failed for node '{0}': {1}", inspectionNode.Name, ex.Message);
|
||||||
|
nodeResult.Status = InspectionNodeStatus.Failed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _store.AppendNodeResultAsync(nodeResult, pipelineSnapshot: pipelineSnapshot, assets: assets);
|
||||||
|
}
|
||||||
|
|
||||||
|
private System.Collections.Generic.IEnumerable<ViewModels.PipelineNodeViewModel> BuildPipelineNodeViewModels(PipelineModel pipeline)
|
||||||
|
{
|
||||||
|
var nodes = new System.Collections.Generic.List<ViewModels.PipelineNodeViewModel>();
|
||||||
|
if (pipeline?.Nodes == null) return nodes;
|
||||||
|
|
||||||
|
foreach (var nodeModel in pipeline.Nodes.OrderBy(n => n.Order))
|
||||||
|
{
|
||||||
|
var displayName = _imageProcessingService?.GetProcessorDisplayName(nodeModel.OperatorKey) ?? nodeModel.OperatorKey;
|
||||||
|
var vm = new ViewModels.PipelineNodeViewModel(nodeModel.OperatorKey, displayName, string.Empty)
|
||||||
|
{
|
||||||
|
Order = nodeModel.Order,
|
||||||
|
IsEnabled = nodeModel.IsEnabled
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载参数定义并恢复保存的值
|
||||||
|
if (_imageProcessingService != null)
|
||||||
|
{
|
||||||
|
var paramDefs = _imageProcessingService.GetProcessorParameters(nodeModel.OperatorKey);
|
||||||
|
if (paramDefs != null)
|
||||||
|
{
|
||||||
|
foreach (var def in paramDefs)
|
||||||
|
{
|
||||||
|
var paramVm = new ViewModels.ProcessorParameterVM(def);
|
||||||
|
if (nodeModel.Parameters != null && nodeModel.Parameters.TryGetValue(def.Name, out var saved))
|
||||||
|
paramVm.Value = saved;
|
||||||
|
vm.Parameters.Add(paramVm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes.Add(vm);
|
||||||
|
}
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] EncodeBitmapToBmp(BitmapSource bitmap)
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
var encoder = new BmpBitmapEncoder();
|
||||||
|
encoder.Frames.Add(BitmapFrame.Create(bitmap));
|
||||||
|
encoder.Save(ms);
|
||||||
|
return ms.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ExecuteWaitDelayWithProgressAsync(WaitDelayNode waitNode, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
int totalMs = waitNode.DelayMilliseconds;
|
||||||
|
if (totalMs <= 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const int tickMs = 50;
|
||||||
|
ProgressWindow progressWindow = null;
|
||||||
|
|
||||||
|
await Application.Current.Dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
progressWindow = new ProgressWindow(
|
||||||
|
title: "延时等待",
|
||||||
|
message: $"节点:{waitNode.Name} 等待 {totalMs / 1000.0:F1} 秒",
|
||||||
|
isCancelable: false);
|
||||||
|
progressWindow.Owner = Application.Current.MainWindow;
|
||||||
|
progressWindow.Show();
|
||||||
|
});
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int elapsed = 0;
|
||||||
|
while (elapsed < totalMs)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
int remaining = totalMs - elapsed;
|
||||||
|
int delay = Math.Min(tickMs, remaining);
|
||||||
|
await Task.Delay(delay, cancellationToken);
|
||||||
|
elapsed += delay;
|
||||||
|
|
||||||
|
double pct = Math.Min(100.0 * elapsed / totalMs, 100.0);
|
||||||
|
double remainSec = Math.Max(0, (totalMs - elapsed) / 1000.0);
|
||||||
|
string msg = $"节点:{waitNode.Name} 剩余 {remainSec:F1} 秒";
|
||||||
|
|
||||||
|
progressWindow?.UpdateProgress(msg, pct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
progressWindow?.Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -180,7 +180,7 @@ namespace XplorePlane.Services.MainViewport
|
|||||||
_currentDisplayInfo = _latestManualInfo;
|
_currentDisplayInfo = _latestManualInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.Info("主界面已加载手动图像 {FileName}", fileName);
|
_logger.Info("[图像链路] MainViewportService.SetManualImage:已更新图像 {FileName},触发 StateChanged", fileName);
|
||||||
RaiseStateChanged();
|
RaiseStateChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ using System.Globalization;
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using XP.Common.Logging.Interfaces;
|
using XP.Common.Logging.Interfaces;
|
||||||
using XplorePlane.Events;
|
using XplorePlane.Events;
|
||||||
@@ -26,6 +27,7 @@ namespace XplorePlane.ViewModels.Cnc
|
|||||||
private readonly ICncProgramService _cncProgramService;
|
private readonly ICncProgramService _cncProgramService;
|
||||||
private readonly IEventAggregator _eventAggregator;
|
private readonly IEventAggregator _eventAggregator;
|
||||||
private readonly ILoggerService _logger;
|
private readonly ILoggerService _logger;
|
||||||
|
private readonly ICncExecutionService _cncExecutionService;
|
||||||
|
|
||||||
private CncProgram _currentProgram;
|
private CncProgram _currentProgram;
|
||||||
private ObservableCollection<CncNodeViewModel> _nodes;
|
private ObservableCollection<CncNodeViewModel> _nodes;
|
||||||
@@ -38,17 +40,24 @@ namespace XplorePlane.ViewModels.Cnc
|
|||||||
private Guid? _preferredSelectedNodeId;
|
private Guid? _preferredSelectedNodeId;
|
||||||
private Guid? _pendingInsertAnchorNodeId;
|
private Guid? _pendingInsertAnchorNodeId;
|
||||||
private bool _pendingInsertAfterAnchor;
|
private bool _pendingInsertAfterAnchor;
|
||||||
|
private CancellationTokenSource _cts;
|
||||||
|
private bool _isRunning;
|
||||||
|
private string _statusMessage = "就绪";
|
||||||
|
private string _executionError;
|
||||||
|
private bool _hasExecutionError;
|
||||||
|
|
||||||
public CncEditorViewModel(
|
public CncEditorViewModel(
|
||||||
ICncProgramService cncProgramService,
|
ICncProgramService cncProgramService,
|
||||||
IAppStateService appStateService,
|
IAppStateService appStateService,
|
||||||
IEventAggregator eventAggregator,
|
IEventAggregator eventAggregator,
|
||||||
ILoggerService logger)
|
ILoggerService logger,
|
||||||
|
ICncExecutionService cncExecutionService)
|
||||||
{
|
{
|
||||||
_cncProgramService = cncProgramService ?? throw new ArgumentNullException(nameof(cncProgramService));
|
_cncProgramService = cncProgramService ?? throw new ArgumentNullException(nameof(cncProgramService));
|
||||||
ArgumentNullException.ThrowIfNull(appStateService);
|
ArgumentNullException.ThrowIfNull(appStateService);
|
||||||
_eventAggregator = eventAggregator ?? throw new ArgumentNullException(nameof(eventAggregator));
|
_eventAggregator = eventAggregator ?? throw new ArgumentNullException(nameof(eventAggregator));
|
||||||
_logger = (logger ?? throw new ArgumentNullException(nameof(logger))).ForModule<CncEditorViewModel>();
|
_logger = (logger ?? throw new ArgumentNullException(nameof(logger))).ForModule<CncEditorViewModel>();
|
||||||
|
_cncExecutionService = cncExecutionService ?? throw new ArgumentNullException(nameof(cncExecutionService));
|
||||||
|
|
||||||
_nodes = new ObservableCollection<CncNodeViewModel>();
|
_nodes = new ObservableCollection<CncNodeViewModel>();
|
||||||
_treeNodes = new ObservableCollection<CncNodeViewModel>();
|
_treeNodes = new ObservableCollection<CncNodeViewModel>();
|
||||||
@@ -57,15 +66,15 @@ namespace XplorePlane.ViewModels.Cnc
|
|||||||
new CncProgramTreeRootViewModel(_programDisplayName, _treeNodes)
|
new CncProgramTreeRootViewModel(_programDisplayName, _treeNodes)
|
||||||
};
|
};
|
||||||
|
|
||||||
InsertReferencePointCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.ReferencePoint));
|
InsertReferencePointCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.ReferencePoint), () => !IsRunning);
|
||||||
InsertSaveNodeWithImageCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.SaveNodeWithImage));
|
InsertSaveNodeWithImageCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.SaveNodeWithImage), () => !IsRunning);
|
||||||
InsertSaveNodeCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.SaveNode));
|
InsertSaveNodeCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.SaveNode), () => !IsRunning);
|
||||||
InsertSavePositionCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.SavePosition));
|
InsertSavePositionCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.SavePosition), () => !IsRunning);
|
||||||
InsertInspectionModuleCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.InspectionModule));
|
InsertInspectionModuleCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.InspectionModule), () => !IsRunning);
|
||||||
InsertInspectionMarkerCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.InspectionMarker));
|
InsertInspectionMarkerCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.InspectionMarker), () => !IsRunning);
|
||||||
InsertPauseDialogCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.PauseDialog));
|
InsertPauseDialogCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.PauseDialog), () => !IsRunning);
|
||||||
InsertWaitDelayCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.WaitDelay));
|
InsertWaitDelayCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.WaitDelay), () => !IsRunning);
|
||||||
InsertCompleteProgramCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.CompleteProgram));
|
InsertCompleteProgramCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.CompleteProgram), () => !IsRunning);
|
||||||
|
|
||||||
DeleteNodeCommand = new DelegateCommand(ExecuteDeleteNode, CanExecuteDeleteNode)
|
DeleteNodeCommand = new DelegateCommand(ExecuteDeleteNode, CanExecuteDeleteNode)
|
||||||
.ObservesProperty(() => SelectedNode);
|
.ObservesProperty(() => SelectedNode);
|
||||||
@@ -79,6 +88,9 @@ namespace XplorePlane.ViewModels.Cnc
|
|||||||
NewProgramCommand = new DelegateCommand(ExecuteNewProgram);
|
NewProgramCommand = new DelegateCommand(ExecuteNewProgram);
|
||||||
ExportCsvCommand = new DelegateCommand(ExecuteExportCsv);
|
ExportCsvCommand = new DelegateCommand(ExecuteExportCsv);
|
||||||
|
|
||||||
|
RunCncCommand = new DelegateCommand(async () => await ExecuteRunAsync(), CanExecuteRun);
|
||||||
|
StopCncCommand = new DelegateCommand(ExecuteStop, CanExecuteStop);
|
||||||
|
|
||||||
_logger.Info("CncEditorViewModel initialized");
|
_logger.Info("CncEditorViewModel initialized");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +148,38 @@ namespace XplorePlane.ViewModels.Cnc
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool IsRunning
|
||||||
|
{
|
||||||
|
get => _isRunning;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _isRunning, value))
|
||||||
|
{
|
||||||
|
RunCncCommand.RaiseCanExecuteChanged();
|
||||||
|
StopCncCommand.RaiseCanExecuteChanged();
|
||||||
|
RaiseEditCommandsCanExecuteChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string StatusMessage
|
||||||
|
{
|
||||||
|
get => _statusMessage;
|
||||||
|
private set => SetProperty(ref _statusMessage, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ExecutionError
|
||||||
|
{
|
||||||
|
get => _executionError;
|
||||||
|
private set => SetProperty(ref _executionError, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasExecutionError
|
||||||
|
{
|
||||||
|
get => _hasExecutionError;
|
||||||
|
private set => SetProperty(ref _hasExecutionError, value);
|
||||||
|
}
|
||||||
|
|
||||||
public DelegateCommand InsertReferencePointCommand { get; }
|
public DelegateCommand InsertReferencePointCommand { get; }
|
||||||
public DelegateCommand InsertSaveNodeWithImageCommand { get; }
|
public DelegateCommand InsertSaveNodeWithImageCommand { get; }
|
||||||
public DelegateCommand InsertSaveNodeCommand { get; }
|
public DelegateCommand InsertSaveNodeCommand { get; }
|
||||||
@@ -154,9 +198,14 @@ namespace XplorePlane.ViewModels.Cnc
|
|||||||
public DelegateCommand LoadProgramCommand { get; }
|
public DelegateCommand LoadProgramCommand { get; }
|
||||||
public DelegateCommand NewProgramCommand { get; }
|
public DelegateCommand NewProgramCommand { get; }
|
||||||
public DelegateCommand ExportCsvCommand { get; }
|
public DelegateCommand ExportCsvCommand { get; }
|
||||||
|
public DelegateCommand RunCncCommand { get; }
|
||||||
|
public DelegateCommand StopCncCommand { get; }
|
||||||
|
|
||||||
private void ExecuteInsertNode(CncNodeType nodeType)
|
private void ExecuteInsertNode(CncNodeType nodeType)
|
||||||
{
|
{
|
||||||
|
if (IsRunning)
|
||||||
|
return;
|
||||||
|
|
||||||
if (_currentProgram == null)
|
if (_currentProgram == null)
|
||||||
{
|
{
|
||||||
ExecuteNewProgram();
|
ExecuteNewProgram();
|
||||||
@@ -213,14 +262,15 @@ namespace XplorePlane.ViewModels.Cnc
|
|||||||
|
|
||||||
private bool CanExecuteDeleteNode()
|
private bool CanExecuteDeleteNode()
|
||||||
{
|
{
|
||||||
return SelectedNode != null
|
return !IsRunning
|
||||||
|
&& SelectedNode != null
|
||||||
&& _currentProgram != null
|
&& _currentProgram != null
|
||||||
&& _currentProgram.Nodes.Count > 1;
|
&& _currentProgram.Nodes.Count > 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ExecuteMoveNodeUp(CncNodeViewModel nodeVm)
|
private void ExecuteMoveNodeUp(CncNodeViewModel nodeVm)
|
||||||
{
|
{
|
||||||
if (_currentProgram == null || nodeVm == null || nodeVm.Index <= 0)
|
if (IsRunning || _currentProgram == null || nodeVm == null || nodeVm.Index <= 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -244,7 +294,7 @@ namespace XplorePlane.ViewModels.Cnc
|
|||||||
|
|
||||||
private void ExecuteMoveNodeDown(CncNodeViewModel nodeVm)
|
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;
|
return;
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -396,6 +446,94 @@ namespace XplorePlane.ViewModels.Cnc
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool CanExecuteRun()
|
||||||
|
=> !IsRunning && _currentProgram?.Nodes?.Count > 0;
|
||||||
|
|
||||||
|
private bool CanExecuteStop()
|
||||||
|
=> IsRunning;
|
||||||
|
|
||||||
|
private async Task ExecuteRunAsync()
|
||||||
|
{
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
IsRunning = true;
|
||||||
|
HasExecutionError = false;
|
||||||
|
ExecutionError = null;
|
||||||
|
StatusMessage = $"正在执行:{_currentProgram?.Name ?? "程序"}(共 {_currentProgram?.Nodes?.Count ?? 0} 个节点)";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var progress = new Progress<CncNodeExecutionProgress>(OnExecutionProgress);
|
||||||
|
await _cncExecutionService.ExecuteAsync(_currentProgram, progress, _cts.Token);
|
||||||
|
if (_cts.IsCancellationRequested)
|
||||||
|
StatusMessage = "执行已停止";
|
||||||
|
else
|
||||||
|
StatusMessage = $"执行完成:{_currentProgram?.Name}";
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
StatusMessage = "执行已取消";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.Error(ex, "CNC execution failed");
|
||||||
|
ExecutionError = ex.Message;
|
||||||
|
HasExecutionError = true;
|
||||||
|
StatusMessage = $"执行失败:{ex.Message}";
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
if (progress.State == NodeExecutionState.Running)
|
||||||
|
StatusMessage = $"正在执行节点:{nodeVm.Name}({nodeVm.Index + 1}/{_currentProgram?.Nodes?.Count ?? 0})";
|
||||||
|
else if (progress.State == NodeExecutionState.Failed)
|
||||||
|
{
|
||||||
|
HasExecutionError = true;
|
||||||
|
ExecutionError = $"节点 [{nodeVm.Name}] 执行失败";
|
||||||
|
StatusMessage = $"错误:节点 [{nodeVm.Name}] 执行失败";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
RunCncCommand.RaiseCanExecuteChanged();
|
||||||
|
StopCncCommand.RaiseCanExecuteChanged();
|
||||||
|
}
|
||||||
|
|
||||||
private void OnProgramEdited()
|
private void OnProgramEdited()
|
||||||
{
|
{
|
||||||
IsModified = true;
|
IsModified = true;
|
||||||
@@ -466,6 +604,7 @@ namespace XplorePlane.ViewModels.Cnc
|
|||||||
: Nodes.LastOrDefault();
|
: Nodes.LastOrDefault();
|
||||||
|
|
||||||
_preferredSelectedNodeId = null;
|
_preferredSelectedNodeId = null;
|
||||||
|
RaiseEditCommandsCanExecuteChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void NormalizeDefaultNodeNamesInCurrentProgram()
|
private void NormalizeDefaultNodeNamesInCurrentProgram()
|
||||||
|
|||||||
@@ -8,11 +8,17 @@ using System.ComponentModel;
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Input;
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using XplorePlane.Events;
|
||||||
using XplorePlane.Models;
|
using XplorePlane.Models;
|
||||||
using XplorePlane.Services;
|
using XplorePlane.Services;
|
||||||
|
using XplorePlane.Services.MainViewport;
|
||||||
using XP.Common.Logging.Interfaces;
|
using XP.Common.Logging.Interfaces;
|
||||||
|
using Prism.Events;
|
||||||
|
|
||||||
namespace XplorePlane.ViewModels.Cnc
|
namespace XplorePlane.ViewModels.Cnc
|
||||||
{
|
{
|
||||||
@@ -21,6 +27,9 @@ namespace XplorePlane.ViewModels.Cnc
|
|||||||
private readonly CncEditorViewModel _editorViewModel;
|
private readonly CncEditorViewModel _editorViewModel;
|
||||||
private readonly IImageProcessingService _imageProcessingService;
|
private readonly IImageProcessingService _imageProcessingService;
|
||||||
private readonly IPipelinePersistenceService _persistenceService;
|
private readonly IPipelinePersistenceService _persistenceService;
|
||||||
|
private readonly IPipelineExecutionService _executionService;
|
||||||
|
private readonly IMainViewportService _mainViewportService;
|
||||||
|
private readonly IEventAggregator _eventAggregator;
|
||||||
private readonly ILoggerService _logger;
|
private readonly ILoggerService _logger;
|
||||||
|
|
||||||
private CncNodeViewModel _activeModuleNode;
|
private CncNodeViewModel _activeModuleNode;
|
||||||
@@ -29,17 +38,26 @@ namespace XplorePlane.ViewModels.Cnc
|
|||||||
private string _pipelineFileDisplayName = "未命名模块.xpm";
|
private string _pipelineFileDisplayName = "未命名模块.xpm";
|
||||||
private string _currentFilePath;
|
private string _currentFilePath;
|
||||||
private bool _isSynchronizing;
|
private bool _isSynchronizing;
|
||||||
|
private CancellationTokenSource _debounceCts;
|
||||||
|
|
||||||
|
private const int DebounceDelayMs = 300;
|
||||||
|
|
||||||
public CncInspectionModulePipelineViewModel(
|
public CncInspectionModulePipelineViewModel(
|
||||||
CncEditorViewModel editorViewModel,
|
CncEditorViewModel editorViewModel,
|
||||||
IImageProcessingService imageProcessingService,
|
IImageProcessingService imageProcessingService,
|
||||||
IPipelinePersistenceService persistenceService,
|
IPipelinePersistenceService persistenceService,
|
||||||
ILoggerService logger)
|
ILoggerService logger,
|
||||||
|
IPipelineExecutionService executionService = null,
|
||||||
|
IMainViewportService mainViewportService = null,
|
||||||
|
IEventAggregator eventAggregator = null)
|
||||||
{
|
{
|
||||||
_editorViewModel = editorViewModel ?? throw new ArgumentNullException(nameof(editorViewModel));
|
_editorViewModel = editorViewModel ?? throw new ArgumentNullException(nameof(editorViewModel));
|
||||||
_imageProcessingService = imageProcessingService ?? throw new ArgumentNullException(nameof(imageProcessingService));
|
_imageProcessingService = imageProcessingService ?? throw new ArgumentNullException(nameof(imageProcessingService));
|
||||||
_persistenceService = persistenceService ?? throw new ArgumentNullException(nameof(persistenceService));
|
_persistenceService = persistenceService ?? throw new ArgumentNullException(nameof(persistenceService));
|
||||||
_logger = (logger ?? throw new ArgumentNullException(nameof(logger))).ForModule<CncInspectionModulePipelineViewModel>();
|
_logger = (logger ?? throw new ArgumentNullException(nameof(logger))).ForModule<CncInspectionModulePipelineViewModel>();
|
||||||
|
_executionService = executionService;
|
||||||
|
_mainViewportService = mainViewportService;
|
||||||
|
_eventAggregator = eventAggregator;
|
||||||
|
|
||||||
PipelineNodes = new ObservableCollection<PipelineNodeViewModel>();
|
PipelineNodes = new ObservableCollection<PipelineNodeViewModel>();
|
||||||
|
|
||||||
@@ -373,6 +391,52 @@ namespace XplorePlane.ViewModels.Cnc
|
|||||||
|
|
||||||
_activeModuleNode.Pipeline = BuildPipelineModel();
|
_activeModuleNode.Pipeline = BuildPipelineModel();
|
||||||
StatusMessage = statusMessage;
|
StatusMessage = statusMessage;
|
||||||
|
TriggerDebouncedPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TriggerDebouncedPreview()
|
||||||
|
{
|
||||||
|
if (_executionService == null || _mainViewportService == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var sourceImage = _mainViewportService.CurrentDisplayImage as BitmapSource
|
||||||
|
?? _mainViewportService.LatestManualImage as BitmapSource;
|
||||||
|
if (sourceImage == null)
|
||||||
|
{
|
||||||
|
_logger.Debug("[图像链路][CNC] TriggerDebouncedPreview:无可用源图像,跳过");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_debounceCts?.Cancel();
|
||||||
|
_debounceCts = new CancellationTokenSource();
|
||||||
|
var token = _debounceCts.Token;
|
||||||
|
|
||||||
|
Task.Delay(DebounceDelayMs, token).ContinueWith(t =>
|
||||||
|
{
|
||||||
|
if (!t.IsCanceled)
|
||||||
|
_ = ExecutePreviewAsync(sourceImage, token);
|
||||||
|
}, TaskScheduler.FromCurrentSynchronizationContext());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ExecutePreviewAsync(BitmapSource sourceImage, CancellationToken token)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.Info("[图像链路][CNC] ExecutePreviewAsync:开始执行,节点数={Count}", PipelineNodes.Count);
|
||||||
|
var result = await _executionService.ExecutePipelineAsync(PipelineNodes, sourceImage, null, token);
|
||||||
|
_logger.Info("[图像链路][CNC] ExecutePreviewAsync:执行完成,推送结果图像");
|
||||||
|
_mainViewportService.SetManualImage(result, string.Empty);
|
||||||
|
_eventAggregator?.GetEvent<PipelinePreviewUpdatedEvent>()
|
||||||
|
.Publish(new PipelinePreviewUpdatedPayload(result, StatusMessage));
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger.Debug("[图像链路][CNC] ExecutePreviewAsync:已取消");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.Error(ex, "[图像链路][CNC] ExecutePreviewAsync:执行失败");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private PipelineModel BuildPipelineModel()
|
private PipelineModel BuildPipelineModel()
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ namespace XplorePlane.ViewModels.Cnc
|
|||||||
private CncNode _model;
|
private CncNode _model;
|
||||||
private string _icon;
|
private string _icon;
|
||||||
private bool _isExpanded = true;
|
private bool _isExpanded = true;
|
||||||
|
private NodeExecutionState _executionState = NodeExecutionState.Idle;
|
||||||
|
|
||||||
public CncNodeViewModel(CncNode model, Action<CncNodeViewModel, CncNode> modelChangedCallback)
|
public CncNodeViewModel(CncNode model, Action<CncNodeViewModel, CncNode> modelChangedCallback)
|
||||||
{
|
{
|
||||||
@@ -57,6 +58,24 @@ namespace XplorePlane.ViewModels.Cnc
|
|||||||
|
|
||||||
public bool HasChildren => Children.Count > 0;
|
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 IsReferencePoint => _model is ReferencePointNode;
|
||||||
public bool IsSaveNode => _model is SaveNodeNode;
|
public bool IsSaveNode => _model is SaveNodeNode;
|
||||||
public bool IsSaveNodeWithImage => _model is SaveNodeWithImageNode;
|
public bool IsSaveNodeWithImage => _model is SaveNodeWithImageNode;
|
||||||
@@ -540,6 +559,10 @@ namespace XplorePlane.ViewModels.Cnc
|
|||||||
RaisePropertyChanged(nameof(DialogTitle));
|
RaisePropertyChanged(nameof(DialogTitle));
|
||||||
RaisePropertyChanged(nameof(DialogMessage));
|
RaisePropertyChanged(nameof(DialogMessage));
|
||||||
RaisePropertyChanged(nameof(DelayMilliseconds));
|
RaisePropertyChanged(nameof(DelayMilliseconds));
|
||||||
|
RaisePropertyChanged(nameof(ExecutionState));
|
||||||
|
RaisePropertyChanged(nameof(IsRunningNode));
|
||||||
|
RaisePropertyChanged(nameof(IsSucceededNode));
|
||||||
|
RaisePropertyChanged(nameof(IsFailedNode));
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum MotionAxis
|
private enum MotionAxis
|
||||||
|
|||||||
@@ -357,7 +357,12 @@ namespace XplorePlane.ViewModels
|
|||||||
|
|
||||||
private async Task ExecutePipelineAsync()
|
private async Task ExecutePipelineAsync()
|
||||||
{
|
{
|
||||||
if (SourceImage == null || IsExecuting) return;
|
if (SourceImage == null || IsExecuting)
|
||||||
|
{
|
||||||
|
_logger.Debug("[图像链路] ExecutePipelineAsync:跳过,SourceImage={HasImage},IsExecuting={IsExec}",
|
||||||
|
SourceImage != null, IsExecuting);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (TryReportInvalidParameters())
|
if (TryReportInvalidParameters())
|
||||||
return;
|
return;
|
||||||
@@ -368,6 +373,7 @@ namespace XplorePlane.ViewModels
|
|||||||
|
|
||||||
IsExecuting = true;
|
IsExecuting = true;
|
||||||
SetInfoStatus("正在执行流水线...");
|
SetInfoStatus("正在执行流水线...");
|
||||||
|
_logger.Info("[图像链路] ExecutePipelineAsync:开始执行,节点数={Count}", PipelineNodes.Count);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -379,19 +385,23 @@ namespace XplorePlane.ViewModels
|
|||||||
|
|
||||||
PreviewImage = result;
|
PreviewImage = result;
|
||||||
SetInfoStatus("流水线执行完成");
|
SetInfoStatus("流水线执行完成");
|
||||||
|
_logger.Info("[图像链路] ExecutePipelineAsync:执行完成,准备发布 PipelinePreviewUpdatedEvent");
|
||||||
PublishPipelinePreviewUpdated(result, StatusMessage);
|
PublishPipelinePreviewUpdated(result, StatusMessage);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
SetInfoStatus("流水线执行已取消");
|
SetInfoStatus("流水线执行已取消");
|
||||||
|
_logger.Info("[图像链路] ExecutePipelineAsync:执行已取消");
|
||||||
}
|
}
|
||||||
catch (PipelineExecutionException ex)
|
catch (PipelineExecutionException ex)
|
||||||
{
|
{
|
||||||
SetErrorStatus($"执行失败:{ex.Message}");
|
SetErrorStatus($"执行失败:{ex.Message}");
|
||||||
|
_logger.Warn("[图像链路] ExecutePipelineAsync:执行失败 {Msg}", ex.Message);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
SetErrorStatus($"执行错误:{ex.Message}");
|
SetErrorStatus($"执行错误:{ex.Message}");
|
||||||
|
_logger.Error(ex, "[图像链路] ExecutePipelineAsync:未预期异常");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -491,8 +501,13 @@ namespace XplorePlane.ViewModels
|
|||||||
|
|
||||||
private void PublishPipelinePreviewUpdated(BitmapSource bitmap, string statusMessage)
|
private void PublishPipelinePreviewUpdated(BitmapSource bitmap, string statusMessage)
|
||||||
{
|
{
|
||||||
if (bitmap == null) return;
|
if (bitmap == null)
|
||||||
|
{
|
||||||
|
_logger.Warn("[图像链路] PublishPipelinePreviewUpdated:bitmap 为 null,跳过发布");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.Info("[图像链路] PublishPipelinePreviewUpdated:发布事件,statusMessage={Msg}", statusMessage);
|
||||||
_eventAggregator.GetEvent<PipelinePreviewUpdatedEvent>()
|
_eventAggregator.GetEvent<PipelinePreviewUpdatedEvent>()
|
||||||
.Publish(new PipelinePreviewUpdatedPayload(bitmap, statusMessage));
|
.Publish(new PipelinePreviewUpdatedPayload(bitmap, statusMessage));
|
||||||
}
|
}
|
||||||
@@ -502,6 +517,7 @@ namespace XplorePlane.ViewModels
|
|||||||
if (payload?.Image == null) return;
|
if (payload?.Image == null) return;
|
||||||
if (ReferenceEquals(SourceImage, payload.Image)) return;
|
if (ReferenceEquals(SourceImage, payload.Image)) return;
|
||||||
|
|
||||||
|
_logger.Info("[图像链路] OnManualImageLoaded:收到图像 {File},设置 SourceImage", payload.FileName);
|
||||||
SourceImage = payload.Image;
|
SourceImage = payload.Image;
|
||||||
PreviewImage = payload.Image;
|
PreviewImage = payload.Image;
|
||||||
SetInfoStatus($"已加载图像:{payload.FileName}");
|
SetInfoStatus($"已加载图像:{payload.FileName}");
|
||||||
@@ -514,8 +530,13 @@ namespace XplorePlane.ViewModels
|
|||||||
|
|
||||||
private void TriggerDebouncedExecution()
|
private void TriggerDebouncedExecution()
|
||||||
{
|
{
|
||||||
if (SourceImage == null) return;
|
if (SourceImage == null)
|
||||||
|
{
|
||||||
|
_logger.Debug("[图像链路] TriggerDebouncedExecution:SourceImage 为 null,跳过执行");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.Debug("[图像链路] TriggerDebouncedExecution:触发防抖执行,节点数={Count}", PipelineNodes.Count);
|
||||||
_debounceCts?.Cancel();
|
_debounceCts?.Cancel();
|
||||||
_debounceCts = new CancellationTokenSource();
|
_debounceCts = new CancellationTokenSource();
|
||||||
var token = _debounceCts.Token;
|
var token = _debounceCts.Token;
|
||||||
|
|||||||
@@ -165,6 +165,23 @@ namespace XplorePlane.ViewModels
|
|||||||
_cncPageView = new CncPageView { DataContext = _cncEditorViewModel };
|
_cncPageView = new CncPageView { DataContext = _cncEditorViewModel };
|
||||||
|
|
||||||
_mainViewportService.StateChanged += OnMainViewportStateChanged;
|
_mainViewportService.StateChanged += OnMainViewportStateChanged;
|
||||||
|
_cncEditorViewModel.PropertyChanged += (s, e) =>
|
||||||
|
{
|
||||||
|
if (e.PropertyName == nameof(CncEditorViewModel.StatusMessage))
|
||||||
|
RaisePropertyChanged(nameof(CncStatusMessage));
|
||||||
|
else if (e.PropertyName == nameof(CncEditorViewModel.HasExecutionError))
|
||||||
|
RaisePropertyChanged(nameof(CncHasExecutionError));
|
||||||
|
else if (e.PropertyName == nameof(CncEditorViewModel.IsRunning))
|
||||||
|
{
|
||||||
|
RunCncCommand.RaiseCanExecuteChanged();
|
||||||
|
StopCncCommand.RaiseCanExecuteChanged();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
_cncEditorViewModel.RunCncCommand.CanExecuteChanged += (s, e) => RunCncCommand.RaiseCanExecuteChanged();
|
||||||
|
_cncEditorViewModel.StopCncCommand.CanExecuteChanged += (s, e) => StopCncCommand.RaiseCanExecuteChanged();
|
||||||
|
|
||||||
|
_eventAggregator.GetEvent<PipelinePreviewUpdatedEvent>()
|
||||||
|
.Subscribe(OnPipelinePreviewUpdated, ThreadOption.UIThread);
|
||||||
|
|
||||||
NavigationTree = new ObservableCollection<object>();
|
NavigationTree = new ObservableCollection<object>();
|
||||||
|
|
||||||
@@ -195,6 +212,12 @@ namespace XplorePlane.ViewModels
|
|||||||
InsertSaveNodeCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.InsertSaveNodeCommand.Execute()));
|
InsertSaveNodeCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.InsertSaveNodeCommand.Execute()));
|
||||||
InsertPauseDialogCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.InsertPauseDialogCommand.Execute()));
|
InsertPauseDialogCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.InsertPauseDialogCommand.Execute()));
|
||||||
InsertWaitDelayCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.InsertWaitDelayCommand.Execute()));
|
InsertWaitDelayCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.InsertWaitDelayCommand.Execute()));
|
||||||
|
RunCncCommand = new DelegateCommand(
|
||||||
|
() => ExecuteCncEditorAction(vm => vm.RunCncCommand.Execute()),
|
||||||
|
() => _cncEditorViewModel.RunCncCommand.CanExecute());
|
||||||
|
StopCncCommand = new DelegateCommand(
|
||||||
|
() => ExecuteCncEditorAction(vm => vm.StopCncCommand.Execute()),
|
||||||
|
() => _cncEditorViewModel.StopCncCommand.CanExecute());
|
||||||
|
|
||||||
PointDistanceMeasureCommand = new DelegateCommand(ExecutePointDistanceMeasure);
|
PointDistanceMeasureCommand = new DelegateCommand(ExecutePointDistanceMeasure);
|
||||||
PointLineDistanceMeasureCommand = new DelegateCommand(ExecutePointLineDistanceMeasure);
|
PointLineDistanceMeasureCommand = new DelegateCommand(ExecutePointLineDistanceMeasure);
|
||||||
@@ -226,6 +249,97 @@ namespace XplorePlane.ViewModels
|
|||||||
_logger.Info("MainViewModel 已初始化");
|
_logger.Info("MainViewModel 已初始化");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public string LicenseInfo
|
||||||
|
{
|
||||||
|
get => _licenseInfo;
|
||||||
|
set => SetProperty(ref _licenseInfo, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string CncStatusMessage => _cncEditorViewModel.StatusMessage;
|
||||||
|
public bool CncHasExecutionError => _cncEditorViewModel.HasExecutionError;
|
||||||
|
|
||||||
|
public ObservableCollection<object> NavigationTree { get; set; }
|
||||||
|
|
||||||
|
public DelegateCommand NavigateHomeCommand { get; set; }
|
||||||
|
public DelegateCommand NavigateInspectCommand { get; set; }
|
||||||
|
public DelegateCommand OpenFileCommand { get; set; }
|
||||||
|
public DelegateCommand ExportCommand { get; set; }
|
||||||
|
public DelegateCommand ClearCommand { get; set; }
|
||||||
|
public DelegateCommand EditPropertiesCommand { get; set; }
|
||||||
|
|
||||||
|
public DelegateCommand OpenImageProcessingCommand { get; }
|
||||||
|
public DelegateCommand LoadImageCommand { get; }
|
||||||
|
public DelegateCommand OpenPipelineEditorCommand { get; }
|
||||||
|
public DelegateCommand OpenCncEditorCommand { get; }
|
||||||
|
public DelegateCommand OpenMatrixEditorCommand { get; }
|
||||||
|
public DelegateCommand OpenToolboxCommand { get; }
|
||||||
|
public DelegateCommand OpenLibraryVersionsCommand { get; }
|
||||||
|
public DelegateCommand OpenUserManualCommand { get; }
|
||||||
|
public DelegateCommand OpenCameraSettingsCommand { get; }
|
||||||
|
public DelegateCommand NewCncProgramCommand { get; }
|
||||||
|
public DelegateCommand SaveCncProgramCommand { get; }
|
||||||
|
public DelegateCommand LoadCncProgramCommand { get; }
|
||||||
|
public DelegateCommand InsertReferencePointCommand { get; }
|
||||||
|
public DelegateCommand InsertSavePositionCommand { get; }
|
||||||
|
public DelegateCommand InsertCompleteProgramCommand { get; }
|
||||||
|
public DelegateCommand InsertInspectionMarkerCommand { get; }
|
||||||
|
public DelegateCommand InsertInspectionModuleCommand { get; }
|
||||||
|
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; }
|
||||||
|
public DelegateCommand OpenMotionDebugCommand { get; }
|
||||||
|
public DelegateCommand OpenPlcAddrConfigCommand { get; }
|
||||||
|
public DelegateCommand OpenRaySourceConfigCommand { get; }
|
||||||
|
public DelegateCommand WarmUpCommand { get; }
|
||||||
|
|
||||||
|
public DelegateCommand PointDistanceMeasureCommand { get; }
|
||||||
|
public DelegateCommand PointLineDistanceMeasureCommand { get; }
|
||||||
|
public DelegateCommand AngleMeasureCommand { get; }
|
||||||
|
public DelegateCommand ThroughHoleFillRateMeasureCommand { get; }
|
||||||
|
public DelegateCommand ToggleCrosshairCommand { get; }
|
||||||
|
|
||||||
|
public DelegateCommand OpenLanguageSwitcherCommand { get; }
|
||||||
|
public DelegateCommand OpenRealTimeLogViewerCommand { get; }
|
||||||
|
public DelegateCommand UseLiveDetectorSourceCommand { get; }
|
||||||
|
|
||||||
|
public bool IsMainViewportRealtimeEnabled
|
||||||
|
{
|
||||||
|
get => _mainViewportService.IsRealtimeDisplayEnabled;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (_mainViewportService.IsRealtimeDisplayEnabled == value)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_mainViewportService.SetRealtimeDisplayEnabled(value);
|
||||||
|
RaisePropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsUsingLiveDetectorSource => _mainViewportService.CurrentSourceMode == MainViewportSourceMode.LiveDetector;
|
||||||
|
|
||||||
|
public object ImagePanelContent
|
||||||
|
{
|
||||||
|
get => _imagePanelContent;
|
||||||
|
set => SetProperty(ref _imagePanelContent, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public GridLength ImagePanelWidth
|
||||||
|
{
|
||||||
|
get => _imagePanelWidth;
|
||||||
|
set => SetProperty(ref _imagePanelWidth, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public GridLength ViewportPanelWidth
|
||||||
|
{
|
||||||
|
get => _viewportPanelWidth;
|
||||||
|
set => SetProperty(ref _viewportPanelWidth, value);
|
||||||
|
}
|
||||||
|
|
||||||
private void ShowWindow(Window window, string name)
|
private void ShowWindow(Window window, string name)
|
||||||
{
|
{
|
||||||
window.Owner = Application.Current.MainWindow;
|
window.Owner = Application.Current.MainWindow;
|
||||||
@@ -418,7 +532,14 @@ namespace XplorePlane.ViewModels
|
|||||||
bitmap.EndInit();
|
bitmap.EndInit();
|
||||||
bitmap.Freeze();
|
bitmap.Freeze();
|
||||||
|
|
||||||
|
_logger.Info("[图像链路] ExecuteLoadImage:加载图像 {Path},准备推送到 MainViewportService 和 ManualImageLoadedEvent", dialog.FileName);
|
||||||
_mainViewportService.SetManualImage(bitmap, dialog.FileName);
|
_mainViewportService.SetManualImage(bitmap, dialog.FileName);
|
||||||
|
|
||||||
|
// 同时发布事件,让 PipelineEditorViewModel 收到图像并触发流水线执行
|
||||||
|
_eventAggregator.GetEvent<ManualImageLoadedEvent>()
|
||||||
|
.Publish(new ManualImageLoadedPayload(bitmap, dialog.FileName));
|
||||||
|
_logger.Info("[图像链路] ManualImageLoadedEvent 已发布");
|
||||||
|
|
||||||
RaisePropertyChanged(nameof(IsUsingLiveDetectorSource));
|
RaisePropertyChanged(nameof(IsUsingLiveDetectorSource));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -630,6 +751,15 @@ namespace XplorePlane.ViewModels
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
private void OnPipelinePreviewUpdated(PipelinePreviewUpdatedPayload payload)
|
||||||
|
{
|
||||||
|
if (payload?.Image == null)
|
||||||
|
{
|
||||||
|
_logger.Warn("[图像链路] OnPipelinePreviewUpdated:payload 或 Image 为 null,跳过");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_logger.Info("[图像链路] OnPipelinePreviewUpdated:收到流水线结果图像,推送到 MainViewportService");
|
||||||
|
_mainViewportService.SetManualImage(payload.Image, string.Empty);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
xmlns:local="clr-namespace:XplorePlane.Views.Cnc"
|
xmlns:local="clr-namespace:XplorePlane.Views.Cnc"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:prism="http://prismlibrary.com/"
|
xmlns:prism="http://prismlibrary.com/"
|
||||||
|
xmlns:behaviors="clr-namespace:XplorePlane.Controls"
|
||||||
xmlns:views="clr-namespace:XplorePlane.Views"
|
xmlns:views="clr-namespace:XplorePlane.Views"
|
||||||
xmlns:vm="clr-namespace:XplorePlane.ViewModels.Cnc"
|
xmlns:vm="clr-namespace:XplorePlane.ViewModels.Cnc"
|
||||||
d:DesignHeight="760"
|
d:DesignHeight="760"
|
||||||
@@ -244,6 +245,7 @@
|
|||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<TextBlock
|
<TextBlock
|
||||||
|
x:Name="NodeNameText"
|
||||||
Grid.Column="2"
|
Grid.Column="2"
|
||||||
Margin="3,0,0,0"
|
Margin="3,0,0,0"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
@@ -286,11 +288,28 @@
|
|||||||
<Setter TargetName="NodeCard" Property="Background" Value="#DCEEFF" />
|
<Setter TargetName="NodeCard" Property="Background" Value="#DCEEFF" />
|
||||||
<Setter TargetName="NodeCard" Property="BorderBrush" Value="#71A9DB" />
|
<Setter TargetName="NodeCard" Property="BorderBrush" Value="#71A9DB" />
|
||||||
</DataTrigger>
|
</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>
|
</DataTemplate.Triggers>
|
||||||
</HierarchicalDataTemplate>
|
</HierarchicalDataTemplate>
|
||||||
</TreeView.Resources>
|
</TreeView.Resources>
|
||||||
<TreeView.ItemContainerStyle>
|
<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.ItemContainerStyle>
|
||||||
</TreeView>
|
</TreeView>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -61,12 +61,18 @@ namespace XplorePlane.Views.Cnc
|
|||||||
var imageProcessingService = ContainerLocator.Current.Resolve<IImageProcessingService>();
|
var imageProcessingService = ContainerLocator.Current.Resolve<IImageProcessingService>();
|
||||||
var persistenceService = ContainerLocator.Current.Resolve<IPipelinePersistenceService>();
|
var persistenceService = ContainerLocator.Current.Resolve<IPipelinePersistenceService>();
|
||||||
var logger = ContainerLocator.Current.Resolve<ILoggerService>();
|
var logger = ContainerLocator.Current.Resolve<ILoggerService>();
|
||||||
|
var executionService = ContainerLocator.Current.Resolve<IPipelineExecutionService>();
|
||||||
|
var mainViewportService = ContainerLocator.Current.Resolve<XplorePlane.Services.MainViewport.IMainViewportService>();
|
||||||
|
var eventAggregator = ContainerLocator.Current.Resolve<Prism.Events.IEventAggregator>();
|
||||||
|
|
||||||
_inspectionModulePipelineViewModel = new CncInspectionModulePipelineViewModel(
|
_inspectionModulePipelineViewModel = new CncInspectionModulePipelineViewModel(
|
||||||
editorViewModel,
|
editorViewModel,
|
||||||
imageProcessingService,
|
imageProcessingService,
|
||||||
persistenceService,
|
persistenceService,
|
||||||
logger);
|
logger,
|
||||||
|
executionService,
|
||||||
|
mainViewportService,
|
||||||
|
eventAggregator);
|
||||||
|
|
||||||
InspectionModulePipelineEditor.DataContext = _inspectionModulePipelineViewModel;
|
InspectionModulePipelineEditor.DataContext = _inspectionModulePipelineViewModel;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ namespace XplorePlane.Views
|
|||||||
PipelineListBox.PreviewMouseMove += OnPreviewMouseMove;
|
PipelineListBox.PreviewMouseMove += OnPreviewMouseMove;
|
||||||
PipelineListBox.PreviewMouseLeftButtonUp -= OnPreviewMouseLeftButtonUp;
|
PipelineListBox.PreviewMouseLeftButtonUp -= OnPreviewMouseLeftButtonUp;
|
||||||
PipelineListBox.PreviewMouseLeftButtonUp += OnPreviewMouseLeftButtonUp;
|
PipelineListBox.PreviewMouseLeftButtonUp += OnPreviewMouseLeftButtonUp;
|
||||||
|
PipelineListBox.MouseDoubleClick -= OnMouseDoubleClick;
|
||||||
|
PipelineListBox.MouseDoubleClick += OnMouseDoubleClick;
|
||||||
PipelineListBox.PreviewKeyDown -= OnPreviewKeyDown;
|
PipelineListBox.PreviewKeyDown -= OnPreviewKeyDown;
|
||||||
PipelineListBox.PreviewKeyDown += OnPreviewKeyDown;
|
PipelineListBox.PreviewKeyDown += OnPreviewKeyDown;
|
||||||
}
|
}
|
||||||
@@ -103,7 +105,6 @@ namespace XplorePlane.Views
|
|||||||
|
|
||||||
private void OnPreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
private void OnPreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||||
{
|
{
|
||||||
var vm = DataContext as IPipelineEditorHostViewModel;
|
|
||||||
var clickedNode = FindNodeFromOriginalSource(e.OriginalSource);
|
var clickedNode = FindNodeFromOriginalSource(e.OriginalSource);
|
||||||
|
|
||||||
if (_isInternalDragging)
|
if (_isInternalDragging)
|
||||||
@@ -119,19 +120,32 @@ namespace XplorePlane.Views
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (vm == null || clickedNode == null || IsInteractiveChild(e.OriginalSource))
|
if (clickedNode == null || IsInteractiveChild(e.OriginalSource))
|
||||||
{
|
{
|
||||||
ResetDragState();
|
ResetDragState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 单击只选中,不切换启用状态
|
||||||
PipelineListBox.SelectedItem = clickedNode;
|
PipelineListBox.SelectedItem = clickedNode;
|
||||||
PipelineListBox.Focus();
|
PipelineListBox.Focus();
|
||||||
vm.ToggleOperatorEnabledCommand.Execute(clickedNode);
|
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
ResetDragState();
|
ResetDragState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnMouseDoubleClick(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
var vm = DataContext as IPipelineEditorHostViewModel;
|
||||||
|
var clickedNode = FindNodeFromOriginalSource(e.OriginalSource);
|
||||||
|
|
||||||
|
if (vm == null || clickedNode == null || IsInteractiveChild(e.OriginalSource))
|
||||||
|
return;
|
||||||
|
|
||||||
|
// 双击切换启用/禁用
|
||||||
|
vm.ToggleOperatorEnabledCommand.Execute(clickedNode);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
|
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.Key != Key.Delete || DataContext is not IPipelineEditorHostViewModel vm || vm.SelectedNode == null)
|
if (e.Key != Key.Delete || DataContext is not IPipelineEditorHostViewModel vm || vm.SelectedNode == null)
|
||||||
|
|||||||
@@ -106,6 +106,7 @@
|
|||||||
<StackPanel>
|
<StackPanel>
|
||||||
<telerik:RadRibbonButton
|
<telerik:RadRibbonButton
|
||||||
telerik:ScreenTip.Title="运行"
|
telerik:ScreenTip.Title="运行"
|
||||||
|
Command="{Binding RunCncCommand}"
|
||||||
Size="Large"
|
Size="Large"
|
||||||
SmallImage="/Assets/Icons/run.png"
|
SmallImage="/Assets/Icons/run.png"
|
||||||
Text="运行" />
|
Text="运行" />
|
||||||
@@ -114,6 +115,7 @@
|
|||||||
<telerik:RadRibbonButton
|
<telerik:RadRibbonButton
|
||||||
telerik:ScreenTip.Description="停止"
|
telerik:ScreenTip.Description="停止"
|
||||||
telerik:ScreenTip.Title="停止"
|
telerik:ScreenTip.Title="停止"
|
||||||
|
Command="{Binding StopCncCommand}"
|
||||||
Size="Large"
|
Size="Large"
|
||||||
SmallImage="/Assets/Icons/stop.png"
|
SmallImage="/Assets/Icons/stop.png"
|
||||||
Text="停止" />
|
Text="停止" />
|
||||||
@@ -572,7 +574,19 @@
|
|||||||
FontFamily="Microsoft YaHei UI"
|
FontFamily="Microsoft YaHei UI"
|
||||||
FontSize="11"
|
FontSize="11"
|
||||||
Foreground="White"
|
Foreground="White"
|
||||||
Text="{Binding StatusMessage}" />
|
Text="{Binding CncStatusMessage}">
|
||||||
|
<TextBlock.Style>
|
||||||
|
<Style TargetType="TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="White" />
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding CncHasExecutionError}" Value="True">
|
||||||
|
<Setter Property="Foreground" Value="#FF9090" />
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</TextBlock.Style>
|
||||||
|
</TextBlock>
|
||||||
|
|
||||||
<TextBlock
|
<TextBlock
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
@@ -581,7 +595,7 @@
|
|||||||
FontFamily="Consolas"
|
FontFamily="Consolas"
|
||||||
FontSize="11"
|
FontSize="11"
|
||||||
Foreground="White"
|
Foreground="White"
|
||||||
Text="x: 0 y: 0 RGB: 0 0 0" />
|
Text="x: 0 y: 0" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
Reference in New Issue
Block a user