diff --git a/XplorePlane.Tests/Services/AppStateServiceTests.cs b/XplorePlane.Tests/Services/AppStateServiceTests.cs index 9e94c1d..769db21 100644 --- a/XplorePlane.Tests/Services/AppStateServiceTests.cs +++ b/XplorePlane.Tests/Services/AppStateServiceTests.cs @@ -1,7 +1,12 @@ using Moq; +using Prism.Events; using System; using System.Windows; using XP.Common.Logging.Interfaces; +using XP.Hardware.MotionControl.Abstractions; +using XP.Hardware.MotionControl.Abstractions.Enums; +using XP.Hardware.MotionControl.Abstractions.Events; +using XP.Hardware.MotionControl.Services; using XP.Hardware.RaySource.Services; using XplorePlane.Models; using XplorePlane.Services.AppState; @@ -11,30 +16,62 @@ using Xunit.Abstractions; namespace XplorePlane.Tests.Services { /// - /// AppStateService 单元测试。 - /// 验证默认状态值、Dispose 后行为、null 参数校验、CalibrationMatrix 缺失时的错误处理。 + /// AppStateService unit tests. + /// Verifies default values, null guards, dispose behavior, and hardware-driven motion-state sync. /// public class AppStateServiceTests : IDisposable { private readonly AppStateService _service; private readonly Mock _mockRaySource; + private readonly Mock _mockMotionSystem; + private readonly Mock _mockMotionControlService; + private readonly Mock _mockStageX; + private readonly Mock _mockStageY; + private readonly Mock _mockSourceZ; + private readonly Mock _mockDetectorZ; + private readonly Mock _mockDetectorSwing; private readonly Mock _mockLogger; + private readonly EventAggregator _eventAggregator; private readonly ITestOutputHelper _output; public AppStateServiceTests(ITestOutputHelper output) { _output = output; - // Ensure WPF Application exists for Dispatcher if (Application.Current == null) { new Application(); } _mockRaySource = new Mock(); + _mockMotionSystem = new Mock(); + _mockMotionControlService = new Mock(); + _mockStageX = CreateLinearAxis(AxisId.StageX, 0); + _mockStageY = CreateLinearAxis(AxisId.StageY, 0); + _mockSourceZ = CreateLinearAxis(AxisId.SourceZ, 0); + _mockDetectorZ = CreateLinearAxis(AxisId.DetectorZ, 0); + _mockDetectorSwing = CreateRotaryAxis(RotaryAxisId.DetectorSwing, 0); _mockLogger = new Mock(); + _eventAggregator = new EventAggregator(); + + _mockMotionSystem.Setup(x => x.GetLinearAxis(AxisId.StageX)).Returns(_mockStageX.Object); + _mockMotionSystem.Setup(x => x.GetLinearAxis(AxisId.StageY)).Returns(_mockStageY.Object); + _mockMotionSystem.Setup(x => x.GetLinearAxis(AxisId.SourceZ)).Returns(_mockSourceZ.Object); + _mockMotionSystem.Setup(x => x.GetLinearAxis(AxisId.DetectorZ)).Returns(_mockDetectorZ.Object); + _mockMotionSystem.Setup(x => x.GetRotaryAxis(RotaryAxisId.DetectorSwing)).Returns(_mockDetectorSwing.Object); + + _mockMotionControlService + .Setup(x => x.GetCurrentGeometry()) + .Returns((0d, 0d, 1d)); + _mockLogger.Setup(l => l.ForModule()).Returns(_mockLogger.Object); - _service = new AppStateService(_mockRaySource.Object, _mockLogger.Object); + + _service = new AppStateService( + _mockRaySource.Object, + _mockMotionSystem.Object, + _mockMotionControlService.Object, + _eventAggregator, + _mockLogger.Object); } public void Dispose() @@ -42,13 +79,15 @@ namespace XplorePlane.Tests.Services _service.Dispose(); } - // ── 默认状态值验证 ── - [Fact] - public void DefaultState_MotionState_IsDefault() + public void DefaultState_MotionState_IsHardwareSnapshot() { - _output.WriteLine($"MotionState == MotionState.Default: {ReferenceEquals(MotionState.Default, _service.MotionState)}"); - Assert.Same(MotionState.Default, _service.MotionState); + Assert.Equal(0, _service.MotionState.XM); + Assert.Equal(0, _service.MotionState.YM); + Assert.Equal(0, _service.MotionState.ZT); + Assert.Equal(0, _service.MotionState.ZD); + Assert.Equal(0, _service.MotionState.TiltD); + Assert.Equal(0, _service.MotionState.Dist); } [Fact] @@ -72,8 +111,6 @@ namespace XplorePlane.Tests.Services Assert.Null(_service.CalibrationMatrix); } - // ── null 参数抛出 ArgumentNullException ── - [Fact] public void UpdateMotionState_NullArgument_ThrowsArgumentNullException() { @@ -102,36 +139,66 @@ namespace XplorePlane.Tests.Services _output.WriteLine($"UpdateSystemState(null) threw: {ex.GetType().Name}, Param={ex.ParamName}"); } - // ── Dispose 后 Update 被忽略 ── - [Fact] public void Dispose_ThenUpdate_IsIgnored() { var originalState = _service.MotionState; _service.Dispose(); - _output.WriteLine("Service disposed, attempting UpdateMotionState..."); - // Should not throw, and state should remain unchanged var newState = new MotionState(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12); _service.UpdateMotionState(newState); - _output.WriteLine($"State unchanged after dispose: {ReferenceEquals(originalState, _service.MotionState)}"); Assert.Same(originalState, _service.MotionState); } - // ── CalibrationMatrix 为 null 时 RequestLinkedView 设置错误状态 ── - [Fact] public void RequestLinkedView_NoCalibrationMatrix_SetsErrorState() { - // CalibrationMatrix is null by default Assert.Null(_service.CalibrationMatrix); _service.RequestLinkedView(100.0, 200.0); - _output.WriteLine($"RequestLinkedView(100, 200) without CalibrationMatrix: HasError={_service.SystemState.HasError}, ErrorMessage='{_service.SystemState.ErrorMessage}'"); Assert.True(_service.SystemState.HasError); Assert.NotEmpty(_service.SystemState.ErrorMessage); } + + [Fact] + public void GeometryUpdatedEvent_RefreshesMotionStateFromHardware() + { + _mockStageX.SetupGet(x => x.ActualPosition).Returns(12.5); + _mockStageY.SetupGet(x => x.ActualPosition).Returns(34.5); + _mockSourceZ.SetupGet(x => x.ActualPosition).Returns(56.5); + _mockDetectorZ.SetupGet(x => x.ActualPosition).Returns(78.5); + _mockDetectorSwing.SetupGet(x => x.ActualAngle).Returns(9.5); + + _eventAggregator.GetEvent() + .Publish(new GeometryData(100, 222.2, 2.22)); + + Assert.Equal(12.5, _service.MotionState.XM); + Assert.Equal(34.5, _service.MotionState.YM); + Assert.Equal(56.5, _service.MotionState.ZT); + Assert.Equal(78.5, _service.MotionState.ZD); + Assert.Equal(9.5, _service.MotionState.TiltD); + Assert.Equal(222.2, _service.MotionState.Dist); + } + + private static Mock CreateLinearAxis(AxisId axisId, double position) + { + var axis = new Mock(); + axis.SetupGet(x => x.Id).Returns(axisId); + axis.SetupGet(x => x.ActualPosition).Returns(position); + axis.SetupGet(x => x.Status).Returns(AxisStatus.Idle); + return axis; + } + + private static Mock CreateRotaryAxis(RotaryAxisId axisId, double angle) + { + var axis = new Mock(); + axis.SetupGet(x => x.Id).Returns(axisId); + axis.SetupGet(x => x.ActualAngle).Returns(angle); + axis.SetupGet(x => x.Status).Returns(AxisStatus.Idle); + axis.SetupGet(x => x.Enabled).Returns(true); + return axis; + } } -} \ No newline at end of file +} diff --git a/XplorePlane/Services/AppState/AppStateService.cs b/XplorePlane/Services/AppState/AppStateService.cs index 9e6abcc..83579c4 100644 --- a/XplorePlane/Services/AppState/AppStateService.cs +++ b/XplorePlane/Services/AppState/AppStateService.cs @@ -1,29 +1,40 @@ +using Prism.Events; using Prism.Mvvm; using System; using System.Threading; using System.Windows; using System.Windows.Threading; using XP.Common.Logging.Interfaces; +using XP.Hardware.MotionControl.Abstractions; +using XP.Hardware.MotionControl.Abstractions.Enums; +using XP.Hardware.MotionControl.Abstractions.Events; +using XP.Hardware.MotionControl.Services; using XP.Hardware.RaySource.Services; using XplorePlane.Models; namespace XplorePlane.Services.AppState { /// - /// 全局应用状态管理服务实现。 - /// 继承 BindableBase 以支持 WPF 数据绑定,使用 Interlocked.Exchange 保证线程安全写入, - /// 通过 Dispatcher.BeginInvoke 将事件调度到 UI 线程。 + /// Global application state service. + /// Motion state is synchronized from the motion hardware service layer and + /// mapped into the legacy business model for compatibility. /// public class AppStateService : BindableBase, IAppStateService { private readonly Dispatcher _dispatcher; private readonly IRaySourceService _raySourceService; + private readonly IMotionSystem _motionSystem; + private readonly IMotionControlService _motionControlService; + private readonly IEventAggregator _eventAggregator; private readonly ILoggerService _logger; + private readonly SubscriptionToken _axisStatusChangedToken; + private readonly SubscriptionToken _geometryUpdatedToken; + private bool _disposed; + private GeometryData _latestGeometry; // ── 状态字段(通过 Interlocked.Exchange 原子替换)── private MotionState _motionState = MotionState.Default; - private RaySourceState _raySourceState = RaySourceState.Default; private DetectorState _detectorState = DetectorState.Default; private SystemState _systemState = SystemState.Default; @@ -34,24 +45,16 @@ namespace XplorePlane.Services.AppState // ── 类型化状态变更事件 ── public event EventHandler> MotionStateChanged; - public event EventHandler> RaySourceStateChanged; - public event EventHandler> DetectorStateChanged; - public event EventHandler> SystemStateChanged; - public event EventHandler> CameraStateChanged; - public event EventHandler> LinkedViewStateChanged; - public event EventHandler> RecipeExecutionStateChanged; - public event EventHandler LinkedViewRequested; // ── 状态属性(只读)── public MotionState MotionState => _motionState; - public RaySourceState RaySourceState => _raySourceState; public DetectorState DetectorState => _detectorState; public SystemState SystemState => _systemState; @@ -62,17 +65,34 @@ namespace XplorePlane.Services.AppState public AppStateService( IRaySourceService raySourceService, + IMotionSystem motionSystem, + IMotionControlService motionControlService, + IEventAggregator eventAggregator, ILoggerService logger) { ArgumentNullException.ThrowIfNull(raySourceService); + ArgumentNullException.ThrowIfNull(motionSystem); + ArgumentNullException.ThrowIfNull(motionControlService); + ArgumentNullException.ThrowIfNull(eventAggregator); ArgumentNullException.ThrowIfNull(logger); _raySourceService = raySourceService; + _motionSystem = motionSystem; + _motionControlService = motionControlService; + _eventAggregator = eventAggregator; _logger = logger.ForModule(); - _dispatcher = Application.Current.Dispatcher; + _dispatcher = Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher; + + _geometryUpdatedToken = _eventAggregator + .GetEvent() + .Subscribe(OnGeometryUpdated); + + _axisStatusChangedToken = _eventAggregator + .GetEvent() + .Subscribe(OnAxisStatusChanged); SubscribeToExistingServices(); - _logger.Info("AppStateService 已初始化"); + _logger.Info("AppStateService initialized"); } // ── 状态更新方法 ── @@ -80,17 +100,30 @@ namespace XplorePlane.Services.AppState public void UpdateMotionState(MotionState newState) { ArgumentNullException.ThrowIfNull(newState); - if (_disposed) { _logger.Warn("AppStateService 已释放,忽略 UpdateMotionState 调用"); return; } + if (_disposed) + { + _logger.Warn("AppStateService is disposed, ignoring UpdateMotionState"); + return; + } - var old = Interlocked.Exchange(ref _motionState, newState); - if (ReferenceEquals(old, newState)) return; - RaiseOnDispatcher(old, newState, MotionStateChanged, nameof(MotionState)); + // Keep the legacy API surface, but let the hardware service layer + // remain the source of truth whenever a fresh hardware snapshot is available. + if (TryRefreshMotionStateFromHardware("UpdateMotionState")) + { + return; + } + + SetMotionState(newState); } public void UpdateRaySourceState(RaySourceState newState) { ArgumentNullException.ThrowIfNull(newState); - if (_disposed) { _logger.Warn("AppStateService 已释放,忽略 UpdateRaySourceState 调用"); return; } + if (_disposed) + { + _logger.Warn("AppStateService is disposed, ignoring UpdateRaySourceState"); + return; + } var old = Interlocked.Exchange(ref _raySourceState, newState); if (ReferenceEquals(old, newState)) return; @@ -100,7 +133,11 @@ namespace XplorePlane.Services.AppState public void UpdateDetectorState(DetectorState newState) { ArgumentNullException.ThrowIfNull(newState); - if (_disposed) { _logger.Warn("AppStateService 已释放,忽略 UpdateDetectorState 调用"); return; } + if (_disposed) + { + _logger.Warn("AppStateService is disposed, ignoring UpdateDetectorState"); + return; + } var old = Interlocked.Exchange(ref _detectorState, newState); if (ReferenceEquals(old, newState)) return; @@ -110,7 +147,11 @@ namespace XplorePlane.Services.AppState public void UpdateSystemState(SystemState newState) { ArgumentNullException.ThrowIfNull(newState); - if (_disposed) { _logger.Warn("AppStateService 已释放,忽略 UpdateSystemState 调用"); return; } + if (_disposed) + { + _logger.Warn("AppStateService is disposed, ignoring UpdateSystemState"); + return; + } var old = Interlocked.Exchange(ref _systemState, newState); if (ReferenceEquals(old, newState)) return; @@ -120,7 +161,11 @@ namespace XplorePlane.Services.AppState public void UpdateCameraState(CameraState newState) { ArgumentNullException.ThrowIfNull(newState); - if (_disposed) { _logger.Warn("AppStateService 已释放,忽略 UpdateCameraState 调用"); return; } + if (_disposed) + { + _logger.Warn("AppStateService is disposed, ignoring UpdateCameraState"); + return; + } var old = Interlocked.Exchange(ref _cameraState, newState); if (ReferenceEquals(old, newState)) return; @@ -130,21 +175,26 @@ namespace XplorePlane.Services.AppState public void UpdateCalibrationMatrix(CalibrationMatrix newMatrix) { ArgumentNullException.ThrowIfNull(newMatrix); - if (_disposed) { _logger.Warn("AppStateService 已释放,忽略 UpdateCalibrationMatrix 调用"); return; } + if (_disposed) + { + _logger.Warn("AppStateService is disposed, ignoring UpdateCalibrationMatrix"); + return; + } var old = Interlocked.Exchange(ref _calibrationMatrix, newMatrix); if (ReferenceEquals(old, newMatrix)) return; - _dispatcher.BeginInvoke(() => - { - RaisePropertyChanged(nameof(CalibrationMatrix)); - }); + _dispatcher.BeginInvoke(() => RaisePropertyChanged(nameof(CalibrationMatrix))); } public void UpdateLinkedViewState(LinkedViewState newState) { ArgumentNullException.ThrowIfNull(newState); - if (_disposed) { _logger.Warn("AppStateService 已释放,忽略 UpdateLinkedViewState 调用"); return; } + if (_disposed) + { + _logger.Warn("AppStateService is disposed, ignoring UpdateLinkedViewState"); + return; + } var old = Interlocked.Exchange(ref _linkedViewState, newState); if (ReferenceEquals(old, newState)) return; @@ -154,7 +204,11 @@ namespace XplorePlane.Services.AppState public void UpdateRecipeExecutionState(RecipeExecutionState newState) { ArgumentNullException.ThrowIfNull(newState); - if (_disposed) { _logger.Warn("AppStateService 已释放,忽略 UpdateRecipeExecutionState 调用"); return; } + if (_disposed) + { + _logger.Warn("AppStateService is disposed, ignoring UpdateRecipeExecutionState"); + return; + } var old = Interlocked.Exchange(ref _recipeExecutionState, newState); if (ReferenceEquals(old, newState)) return; @@ -168,11 +222,11 @@ namespace XplorePlane.Services.AppState var matrix = _calibrationMatrix; if (matrix is null) { - _logger.Warn("CalibrationMatrix 未设置,无法执行画面联动 (pixelX={PixelX}, pixelY={PixelY})", pixelX, pixelY); + _logger.Warn("CalibrationMatrix is not configured, cannot execute linked view request (pixelX={PixelX}, pixelY={PixelY})", pixelX, pixelY); UpdateSystemState(SystemState with { HasError = true, - ErrorMessage = "CalibrationMatrix 未设置,无法执行画面联动" + ErrorMessage = "CalibrationMatrix is not configured, cannot execute linked view request" }); return; } @@ -191,10 +245,38 @@ namespace XplorePlane.Services.AppState }); } - // ── 内部辅助方法 ── + public void Dispose() + { + if (_disposed) return; + _disposed = true; - private void RaiseOnDispatcher(T oldVal, T newVal, - EventHandler> handler, string propertyName) + if (_axisStatusChangedToken is not null) + { + _eventAggregator.GetEvent().Unsubscribe(_axisStatusChangedToken); + } + + if (_geometryUpdatedToken is not null) + { + _eventAggregator.GetEvent().Unsubscribe(_geometryUpdatedToken); + } + + MotionStateChanged = null; + RaySourceStateChanged = null; + DetectorStateChanged = null; + SystemStateChanged = null; + CameraStateChanged = null; + LinkedViewStateChanged = null; + RecipeExecutionStateChanged = null; + LinkedViewRequested = null; + + _logger.Info("AppStateService disposed"); + } + + private void RaiseOnDispatcher( + T oldVal, + T newVal, + EventHandler> handler, + string propertyName) { _dispatcher.BeginInvoke(() => { @@ -205,34 +287,85 @@ namespace XplorePlane.Services.AppState } catch (Exception ex) { - _logger.Error(ex, "状态变更事件处理器抛出异常 (property={PropertyName})", propertyName); + _logger.Error(ex, "State changed handler failed (property={PropertyName})", propertyName); } }); } private void SubscribeToExistingServices() { - _logger.Info("AppStateService 已准备好接收外部服务状态更新"); + if (TryRefreshMotionStateFromHardware("initialization")) + { + _logger.Info("AppStateService subscribed to motion hardware state"); + return; + } + + _logger.Warn("AppStateService could not initialize motion state from hardware"); } - // ── Dispose ── - - public void Dispose() + private void OnAxisStatusChanged(AxisStatusChangedData _) { if (_disposed) return; - _disposed = true; + TryRefreshMotionStateFromHardware("axis-status-changed"); + } - // 清除所有事件订阅 - MotionStateChanged = null; - RaySourceStateChanged = null; - DetectorStateChanged = null; - SystemStateChanged = null; - CameraStateChanged = null; - LinkedViewStateChanged = null; - RecipeExecutionStateChanged = null; - LinkedViewRequested = null; + private void OnGeometryUpdated(GeometryData geometry) + { + if (_disposed) return; - _logger.Info("AppStateService 已释放"); + _latestGeometry = geometry; + TryRefreshMotionStateFromHardware("geometry-updated"); + } + + private bool TryRefreshMotionStateFromHardware(string reason) + { + try + { + if (_latestGeometry is null) + { + var geometry = _motionControlService.GetCurrentGeometry(); + _latestGeometry = new GeometryData(geometry.FOD, geometry.FDD, geometry.Magnification); + } + + SetMotionState(BuildMotionStateSnapshot(_latestGeometry)); + return true; + } + catch (Exception ex) + { + _logger.Warn("Failed to refresh motion state from hardware during {Reason}: {Message}", reason, ex.Message); + _logger.Error(ex, "Motion state refresh exception during {Reason}", reason); + return false; + } + } + + private MotionState BuildMotionStateSnapshot(GeometryData geometry) + { + var stageX = _motionSystem.GetLinearAxis(AxisId.StageX); + var stageY = _motionSystem.GetLinearAxis(AxisId.StageY); + var sourceZ = _motionSystem.GetLinearAxis(AxisId.SourceZ); + var detectorZ = _motionSystem.GetLinearAxis(AxisId.DetectorZ); + var detectorSwing = _motionSystem.GetRotaryAxis(RotaryAxisId.DetectorSwing); + + return new MotionState( + XM: stageX.ActualPosition, + YM: stageY.ActualPosition, + ZT: sourceZ.ActualPosition, + ZD: detectorZ.ActualPosition, + TiltD: detectorSwing.ActualAngle, + Dist: geometry?.FDD ?? 0, + XMSpeed: 0, + YMSpeed: 0, + ZTSpeed: 0, + ZDSpeed: 0, + TiltDSpeed: 0, + DistSpeed: 0); + } + + private void SetMotionState(MotionState newState) + { + var old = Interlocked.Exchange(ref _motionState, newState); + if (ReferenceEquals(old, newState)) return; + RaiseOnDispatcher(old, newState, MotionStateChanged, nameof(MotionState)); } } -} \ No newline at end of file +} diff --git a/XplorePlane/ViewModels/Cnc/CncInspectionModulePipelineViewModel.cs b/XplorePlane/ViewModels/Cnc/CncInspectionModulePipelineViewModel.cs index f16a061..be00d31 100644 --- a/XplorePlane/ViewModels/Cnc/CncInspectionModulePipelineViewModel.cs +++ b/XplorePlane/ViewModels/Cnc/CncInspectionModulePipelineViewModel.cs @@ -1,4 +1,4 @@ -using Microsoft.Win32; +using Microsoft.Win32; using Prism.Commands; using Prism.Mvvm; using System; diff --git a/XplorePlane/ViewModels/ImageProcessing/PipelineEditorViewModel.cs b/XplorePlane/ViewModels/ImageProcessing/PipelineEditorViewModel.cs index c79ffe1..2494bfd 100644 --- a/XplorePlane/ViewModels/ImageProcessing/PipelineEditorViewModel.cs +++ b/XplorePlane/ViewModels/ImageProcessing/PipelineEditorViewModel.cs @@ -1,4 +1,4 @@ -using Microsoft.Win32; +using Microsoft.Win32; using Prism.Events; using Prism.Commands; using Prism.Mvvm; diff --git a/XplorePlane/ViewModels/Main/MainViewModel.cs b/XplorePlane/ViewModels/Main/MainViewModel.cs index fbb4497..8b12f86 100644 --- a/XplorePlane/ViewModels/Main/MainViewModel.cs +++ b/XplorePlane/ViewModels/Main/MainViewModel.cs @@ -1,4 +1,4 @@ -using Microsoft.Win32; +using Microsoft.Win32; using Prism.Commands; using Prism.Events; using Prism.Ioc; diff --git a/XplorePlane/Views/Cnc/CncEditorWindow.xaml b/XplorePlane/Views/Cnc/CncEditorWindow.xaml index a709fcf..18409ad 100644 --- a/XplorePlane/Views/Cnc/CncEditorWindow.xaml +++ b/XplorePlane/Views/Cnc/CncEditorWindow.xaml @@ -1,4 +1,4 @@ - + WinExe net8.0-windows diff --git a/XplorePlane/XplorePlane.csproj.user b/XplorePlane/XplorePlane.csproj.user index 88a5509..824d5a9 100644 --- a/XplorePlane/XplorePlane.csproj.user +++ b/XplorePlane/XplorePlane.csproj.user @@ -1,4 +1,4 @@ - + \ No newline at end of file