// 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.Services.Storage; using XplorePlane.Services; using XplorePlane.Services.Permission; using XplorePlane.ViewModels.Cnc; using Xunit; namespace XplorePlane.Tests.ViewModels { public class CncEditorViewModelTests { // ── Helpers ────────────────────────────────────────────────────────────────── private static CncEditorViewModel CreateVm( Mock mockExecSvc = null, CncProgram initialProgram = null, Mock mockPipelinePersistenceService = null) { var mockCncProgramSvc = new Mock(); var mockAppState = new Mock(); var mockLogger = new Mock(); var mockDataPathService = new Mock(); var mockPermissionService = new Mock(); mockPipelinePersistenceService ??= new Mock(); mockLogger.Setup(l => l.ForModule()).Returns(mockLogger.Object); mockDataPathService.SetupGet(s => s.PlanPath).Returns(System.IO.Path.GetTempPath()); // Default: grant all permissions (Admin/SuperAdmin behavior) mockPermissionService.Setup(p => p.HasPermission(It.IsAny())).Returns(true); mockExecSvc ??= new Mock(); // Setup CreateProgram so ExecuteNewProgram works mockCncProgramSvc .Setup(s => s.CreateProgram(It.IsAny())) .Returns((string name) => new CncProgram( Guid.NewGuid(), name, DateTime.UtcNow, DateTime.UtcNow, new List { new ReferencePointNode(Guid.NewGuid(), 0, "参考点_0", 0, 0, 0, 0, 0, 0, false, 0, 0) }.AsReadOnly())); mockCncProgramSvc .Setup(s => s.CreateNode(It.IsAny())) .Returns((CncNodeType nodeType) => nodeType switch { CncNodeType.InspectionModule => new InspectionModuleNode(Guid.NewGuid(), 0, "检测模块_0", new PipelineModel()), CncNodeType.ReferencePoint => new ReferencePointNode(Guid.NewGuid(), 0, "参考点_0", 0, 0, 0, 0, 0, 0, false, 0, 0), _ => throw new InvalidOperationException($"Unsupported node type in test: {nodeType}") }); mockCncProgramSvc .Setup(s => s.InsertNode(It.IsAny(), It.IsAny(), It.IsAny())) .Returns((CncProgram program, int afterIndex, CncNode node) => { var nodes = program.Nodes.ToList(); var insertIndex = Math.Clamp(afterIndex + 1, 0, nodes.Count); nodes.Insert(insertIndex, node with { Index = insertIndex }); for (var i = 0; i < nodes.Count; i++) { nodes[i] = nodes[i] with { Index = i }; } return program with { Nodes = nodes.AsReadOnly(), UpdatedAt = DateTime.UtcNow }; }); mockCncProgramSvc .Setup(s => s.UpdateNode(It.IsAny(), It.IsAny(), It.IsAny())) .Returns((CncProgram program, int index, CncNode updatedNode) => { var nodes = program.Nodes.ToList(); nodes[index] = updatedNode with { Index = index }; return program with { Nodes = nodes.AsReadOnly(), UpdatedAt = DateTime.UtcNow }; }); var vm = new CncEditorViewModel( mockCncProgramSvc.Object, mockAppState.Object, new EventAggregator(), mockLogger.Object, mockExecSvc.Object, mockDataPathService.Object, mockPipelinePersistenceService.Object, mockPermissionService.Object); if (initialProgram != null) { // Use reflection to set _currentProgram and refresh nodes var field = typeof(CncEditorViewModel) .GetField("_currentProgram", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); field?.SetValue(vm, initialProgram); var refresh = typeof(CncEditorViewModel) .GetMethod("RefreshNodes", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); refresh?.Invoke(vm, null); } return vm; } private static CncProgram MakeProgram(int nodeCount = 2) { var nodes = Enumerable.Range(0, nodeCount) .Select(i => (CncNode)new ReferencePointNode(Guid.NewGuid(), i, $"Node_{i}", 0, 0, 0, 0, 0, 0, false, 0, 0)) .ToList() .AsReadOnly(); return new CncProgram(Guid.NewGuid(), "TestProgram", DateTime.UtcNow, DateTime.UtcNow, nodes); } // ── Property 1: 运行/停止按钮状态互斥 ────────────────────────────────────────── // Feature: cnc-run-execution, Property 1: 运行/停止按钮状态互斥 // Validates: Requirements 1.1, 1.3, 1.4 [Property(MaxTest = 100)] public Property RunStop_Commands_AreMutuallyExclusive() { var gen = from nodeCount in Gen.Choose(1, 8) select MakeProgram(nodeCount); return Prop.ForAll(gen.ToArbitrary(), program => { var tcs = new TaskCompletionSource(); var mockExecSvc = new Mock(); mockExecSvc .Setup(s => s.ExecuteAsync( It.IsAny(), It.IsAny>(), It.IsAny())) .Returns(tcs.Task); var vm = CreateVm(mockExecSvc, program); // Before running: Run enabled, Stop disabled bool beforeRunOk = vm.RunCncCommand.CanExecute() && !vm.StopCncCommand.CanExecute(); // Start execution (fire-and-forget, task is blocked by tcs) _ = Task.Run(() => vm.RunCncCommand.Execute()); // Give the async method a moment to set IsRunning = true SpinWait.SpinUntil(() => vm.IsRunning, TimeSpan.FromMilliseconds(500)); bool duringRunOk = !vm.RunCncCommand.CanExecute() && vm.StopCncCommand.CanExecute(); // Complete execution tcs.SetResult(true); SpinWait.SpinUntil(() => !vm.IsRunning, TimeSpan.FromMilliseconds(500)); bool afterRunOk = vm.RunCncCommand.CanExecute() && !vm.StopCncCommand.CanExecute(); return beforeRunOk && duringRunOk && afterRunOk; }); } // ── Property 2: 执行完成后状态重置 ────────────────────────────────────────────── // Feature: cnc-run-execution, Property 2: 执行完成后状态重置 // Validates: Requirements 1.7, 6.5 [Property(MaxTest = 100)] public Property AfterExecution_IsRunningFalse_AllNodesIdle() { var gen = from nodeCount in Gen.Choose(1, 6) from cancelled in ArbMap.Default.GeneratorFor() select (nodeCount, cancelled); return Prop.ForAll(gen.ToArbitrary(), tuple => { var (nodeCount, cancelled) = tuple; var program = MakeProgram(nodeCount); var tcs = new TaskCompletionSource(); var mockExecSvc = new Mock(); mockExecSvc .Setup(s => s.ExecuteAsync( It.IsAny(), It.IsAny>(), It.IsAny())) .Returns(tcs.Task); var vm = CreateVm(mockExecSvc, program); // Start execution _ = Task.Run(() => vm.RunCncCommand.Execute()); SpinWait.SpinUntil(() => vm.IsRunning, TimeSpan.FromMilliseconds(500)); // Simulate some nodes going Running foreach (var node in vm.Nodes) node.ExecutionState = NodeExecutionState.Running; // Complete or cancel if (cancelled) tcs.SetCanceled(); else tcs.SetResult(true); SpinWait.SpinUntil(() => !vm.IsRunning, TimeSpan.FromMilliseconds(500)); bool isRunningFalse = !vm.IsRunning; bool allIdle = vm.Nodes.All(n => n.ExecutionState == NodeExecutionState.Idle); return isRunningFalse && allIdle; }); } // ── Property 12: 执行中编辑命令全部禁用 ────────────────────────────────────────── // Feature: cnc-run-execution, Property 12: 执行中编辑命令全部禁用 // Validates: Requirements 6.7 [Property(MaxTest = 100)] public Property WhileRunning_AllEditCommands_AreDisabled() { var gen = from nodeCount in Gen.Choose(2, 8) select MakeProgram(nodeCount); return Prop.ForAll(gen.ToArbitrary(), program => { var tcs = new TaskCompletionSource(); var mockExecSvc = new Mock(); mockExecSvc .Setup(s => s.ExecuteAsync( It.IsAny(), It.IsAny>(), It.IsAny())) .Returns(tcs.Task); var vm = CreateVm(mockExecSvc, program); // Start execution _ = Task.Run(() => vm.RunCncCommand.Execute()); SpinWait.SpinUntil(() => vm.IsRunning, TimeSpan.FromMilliseconds(500)); // All insert/delete/move commands must be disabled var editCommands = new[] { vm.InsertReferencePointCommand.CanExecute(), vm.InsertSaveNodeWithImageCommand.CanExecute(), vm.InsertSaveNodeCommand.CanExecute(), vm.InsertSavePositionCommand.CanExecute(), vm.InsertInspectionModuleCommand.CanExecute(), vm.InsertInspectionMarkerCommand.CanExecute(), vm.InsertPauseDialogCommand.CanExecute(), vm.InsertWaitDelayCommand.CanExecute(), vm.InsertCompleteProgramCommand.CanExecute(), vm.DeleteNodeCommand.CanExecute(), }; bool allDisabled = editCommands.All(canExec => !canExec); // Cleanup tcs.SetResult(true); SpinWait.SpinUntil(() => !vm.IsRunning, TimeSpan.FromMilliseconds(500)); return allDisabled; }); } [Fact] public async Task InsertInspectionModuleFromPipelineFileAsync_LoadsPipelineAndInsertsNode() { var pipelineFile = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"{Guid.NewGuid():N}.xpm"); await System.IO.File.WriteAllTextAsync(pipelineFile, "{}"); try { var expectedPipeline = new PipelineModel { Name = "BuiltIn/ModuleA" }; var mockPipelinePersistenceService = new Mock(); mockPipelinePersistenceService .Setup(s => s.LoadAsync(pipelineFile)) .ReturnsAsync(expectedPipeline); var initialProgram = new CncProgram( Guid.NewGuid(), "TestProgram", DateTime.UtcNow, DateTime.UtcNow, new List { new SavePositionNode(Guid.NewGuid(), 0, "保存位置_0", MotionState.Default) }.AsReadOnly()); var vm = CreateVm( initialProgram: initialProgram, mockPipelinePersistenceService: mockPipelinePersistenceService); await vm.InsertInspectionModuleFromPipelineFileAsync(pipelineFile); var insertedNode = Assert.IsType(vm.Nodes.Last().Model); Assert.Same(expectedPipeline, insertedNode.Pipeline); Assert.Equal("BuiltIn/ModuleA", insertedNode.Pipeline.Name); } finally { if (System.IO.File.Exists(pipelineFile)) System.IO.File.Delete(pipelineFile); } } } }