Files
XplorePlane/XplorePlane.Tests/ViewModels/CncEditorViewModelTests.cs
T
zhengxuan.zhang 741874e85d 基于角色的权限控制
1、用户角色枚举、权限枚举、结果记录和密码存储模型
IPermissionService 接口及包含认证、权限检查、密码管理和登出功能的 PermissionService 单例
2、支持层级化角色-权限映射的权限矩阵(SuperAdmin ⊇ Admin ⊇ User)
密码持久化至 passwords.json 文件,并提供工厂默认值回退机制
3、UI 层
LoginDialog — 启动时弹出模态登录对话框,支持密码掩码输入、错误提示以及取消退出功能
RibbonStatusAreaView — 在Ribbon右侧区域始终显示角色标签和“切换用户”按钮
权限感知的CncEditorViewModel — 用户角色无法使用CNC编辑控件
权限感知的CncInspectionModulePipelineViewModel — 用户角色无法进行流程编辑
设置导航可见性 — Admin/User角色隐藏Factory_Settings,User角色隐藏Report_Settings
PasswordManagementView — 仅SuperAdmin可访问的修改角色密码对话框
PermissionTooltipHelper — 附加属性,在禁用控件上显示“当前角色无权访问此功能”提示
2026-06-01 17:15:59 +08:00

327 lines
14 KiB
C#

// 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<ICncExecutionService> mockExecSvc = null,
CncProgram initialProgram = null,
Mock<IPipelinePersistenceService> mockPipelinePersistenceService = null)
{
var mockCncProgramSvc = new Mock<ICncProgramService>();
var mockAppState = new Mock<IAppStateService>();
var mockLogger = new Mock<ILoggerService>();
var mockDataPathService = new Mock<IXpDataPathService>();
var mockPermissionService = new Mock<IPermissionService>();
mockPipelinePersistenceService ??= new Mock<IPipelinePersistenceService>();
mockLogger.Setup(l => l.ForModule<CncEditorViewModel>()).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<XplorePlane.Models.Permission>())).Returns(true);
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()));
mockCncProgramSvc
.Setup(s => s.CreateNode(It.IsAny<CncNodeType>()))
.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<CncProgram>(), It.IsAny<int>(), It.IsAny<CncNode>()))
.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<CncProgram>(), It.IsAny<int>(), It.IsAny<CncNode>()))
.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<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;
});
}
[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<IPipelinePersistenceService>();
mockPipelinePersistenceService
.Setup(s => s.LoadAsync(pipelineFile))
.ReturnsAsync(expectedPipeline);
var initialProgram = new CncProgram(
Guid.NewGuid(),
"TestProgram",
DateTime.UtcNow,
DateTime.UtcNow,
new List<CncNode>
{
new SavePositionNode(Guid.NewGuid(), 0, "保存位置_0", MotionState.Default)
}.AsReadOnly());
var vm = CreateVm(
initialProgram: initialProgram,
mockPipelinePersistenceService: mockPipelinePersistenceService);
await vm.InsertInspectionModuleFromPipelineFileAsync(pipelineFile);
var insertedNode = Assert.IsType<InspectionModuleNode>(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);
}
}
}
}