CNC执行逻辑的开发,点击运行,停止
This commit is contained in:
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user