diff --git a/XplorePlane.Tests/Services/DebugPanelConfigServiceTests.cs b/XplorePlane.Tests/Services/DebugPanelConfigServiceTests.cs new file mode 100644 index 0000000..616af89 --- /dev/null +++ b/XplorePlane.Tests/Services/DebugPanelConfigServiceTests.cs @@ -0,0 +1,338 @@ +using Moq; +using System; +using System.Collections.Generic; +using System.IO; +using System.Windows; +using Xunit; +using XP.Common.Logging.Interfaces; +using XplorePlane.Models; +using XplorePlane.Services.Debug; + +namespace XplorePlane.Tests.Services +{ + /// + /// 测试 DebugPanelConfigService(任务 1.2) + /// Tests DebugPanelConfigService (Task 1.2) + /// + /// **Validates: Requirements 12.1-12.7** + /// + public class DebugPanelConfigServiceTests : IDisposable + { + private readonly string _tempDir; + private readonly string _configPath; + private readonly Mock _mockLogger; + private readonly Mock _mockModuleLogger; + private readonly DebugPanelConfigService _service; + + public DebugPanelConfigServiceTests() + { + // 创建临时测试目录 + _tempDir = Path.Combine(Path.GetTempPath(), "XplorePlaneTests", Guid.NewGuid().ToString()); + Directory.CreateDirectory(_tempDir); + _configPath = Path.Combine(_tempDir, "DebugPanel.config"); + + // 设置 mock logger + _mockModuleLogger = new Mock(); + _mockLogger = new Mock(); + _mockLogger.Setup(l => l.ForModule()) + .Returns(_mockModuleLogger.Object); + + // 使用反射创建服务实例并设置配置路径 + _service = new DebugPanelConfigService(_mockLogger.Object); + + // 使用反射修改私有字段 _configPath + var field = typeof(DebugPanelConfigService).GetField("_configPath", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + field?.SetValue(_service, _configPath); + } + + public void Dispose() + { + // 清理临时目录 + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, true); + } + } + + /// + /// 测试:当配置文件不存在时,LoadConfig 应返回默认配置 + /// **Validates: Requirement 12.7** + /// + [Fact] + public void LoadConfig_WhenFileDoesNotExist_ReturnsDefaultConfig() + { + // Act + var config = _service.LoadConfig(); + + // Assert + Assert.NotNull(config); + Assert.NotNull(config.Window); + Assert.Equal(1200, config.Window.Width); + Assert.Equal(800, config.Window.Height); + Assert.Equal(WindowState.Normal, config.Window.State); + Assert.NotNull(config.EventFilters); + Assert.Equal(8, config.EventFilters.Count); + Assert.True(config.EventFilters["MotionState"]); + Assert.True(config.EventFilters["RaySourceState"]); + Assert.True(config.EventFilters["DetectorState"]); + Assert.True(config.EventFilters["SystemState"]); + Assert.True(config.EventFilters["CameraState"]); + Assert.True(config.EventFilters["LinkedViewState"]); + Assert.True(config.EventFilters["RecipeExecutionState"]); + Assert.True(config.EventFilters["CalibrationMatrix"]); + } + + /// + /// 测试:SaveConfig 应成功保存配置到 JSON 文件 + /// **Validates: Requirements 12.1-12.5** + /// + [Fact] + public void SaveConfig_WithValidConfig_SavesSuccessfully() + { + // Arrange + var config = new DebugPanelConfig + { + Window = new WindowConfig + { + Left = 150, + Top = 200, + Width = 1400, + Height = 900, + State = WindowState.Maximized + }, + EventFilters = new Dictionary + { + ["MotionState"] = false, + ["RaySourceState"] = true + }, + DockingLayout = "Test" + }; + + // Act + _service.SaveConfig(config); + + // Assert + Assert.True(File.Exists(_configPath)); + var json = File.ReadAllText(_configPath); + Assert.Contains("\"Width\": 1400", json); + Assert.Contains("\"Height\": 900", json); + Assert.Contains("\"State\": 2", json); // WindowState.Maximized = 2 + Assert.Contains("\"MotionState\": false", json); + Assert.Contains("\"RaySourceState\": true", json); + Assert.Contains("\"DockingLayout\": \"Test\"", json); + } + + /// + /// 测试:LoadConfig 应成功从 JSON 文件加载配置 + /// **Validates: Requirement 12.6** + /// + [Fact] + public void LoadConfig_WithExistingFile_LoadsSuccessfully() + { + // Arrange + var originalConfig = new DebugPanelConfig + { + Window = new WindowConfig + { + Left = 250, + Top = 300, + Width = 1600, + Height = 1000, + State = WindowState.Normal + }, + EventFilters = new Dictionary + { + ["MotionState"] = true, + ["RaySourceState"] = false, + ["DetectorState"] = true + }, + DockingLayout = "Custom" + }; + _service.SaveConfig(originalConfig); + + // Act + var loadedConfig = _service.LoadConfig(); + + // Assert + Assert.NotNull(loadedConfig); + Assert.NotNull(loadedConfig.Window); + Assert.Equal(250, loadedConfig.Window.Left); + Assert.Equal(300, loadedConfig.Window.Top); + Assert.Equal(1600, loadedConfig.Window.Width); + Assert.Equal(1000, loadedConfig.Window.Height); + Assert.Equal(WindowState.Normal, loadedConfig.Window.State); + Assert.NotNull(loadedConfig.EventFilters); + Assert.True(loadedConfig.EventFilters["MotionState"]); + Assert.False(loadedConfig.EventFilters["RaySourceState"]); + Assert.True(loadedConfig.EventFilters["DetectorState"]); + Assert.Equal("Custom", loadedConfig.DockingLayout); + } + + /// + /// 测试:SaveConfig 应创建不存在的目录 + /// **Validates: Requirement 12.4** + /// + [Fact] + public void SaveConfig_WhenDirectoryDoesNotExist_CreatesDirectory() + { + // Arrange + var nestedPath = Path.Combine(_tempDir, "nested", "path", "DebugPanel.config"); + var field = typeof(DebugPanelConfigService).GetField("_configPath", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + field?.SetValue(_service, nestedPath); + + var config = new DebugPanelConfig + { + Window = new WindowConfig { Width = 1200, Height = 800, State = WindowState.Normal }, + EventFilters = new Dictionary() + }; + + // Act + _service.SaveConfig(config); + + // Assert + Assert.True(File.Exists(nestedPath)); + Assert.True(Directory.Exists(Path.GetDirectoryName(nestedPath))); + } + + /// + /// 测试:SaveConfig 使用 null 配置应记录警告并不抛出异常 + /// **Validates: Requirement 12.7** + /// + [Fact] + public void SaveConfig_WithNullConfig_LogsWarningAndDoesNotThrow() + { + // Act & Assert + var exception = Record.Exception(() => _service.SaveConfig(null)); + Assert.Null(exception); + + // 验证记录了警告日志 + _mockModuleLogger.Verify( + l => l.Warn(It.IsAny(), It.IsAny()), + Times.Once); + } + + /// + /// 测试:LoadConfig 遇到损坏的 JSON 文件应返回默认配置并记录警告 + /// **Validates: Requirement 12.7** + /// + [Fact] + public void LoadConfig_WithCorruptedFile_ReturnsDefaultConfigAndLogsWarning() + { + // Arrange + File.WriteAllText(_configPath, "{ invalid json content }"); + + // Act + var config = _service.LoadConfig(); + + // Assert + Assert.NotNull(config); + Assert.Equal(1200, config.Window.Width); + Assert.Equal(800, config.Window.Height); + + // 验证记录了错误日志 + _mockModuleLogger.Verify( + l => l.Error(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Once); + } + + /// + /// 测试:配置文件路径应使用 %AppData%\XplorePlane\DebugPanel.config + /// **Validates: Requirement 12.4** + /// + [Fact] + public void Constructor_SetsCorrectConfigPath() + { + // Arrange + var freshService = new DebugPanelConfigService(_mockLogger.Object); + var field = typeof(DebugPanelConfigService).GetField("_configPath", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + // Act + var actualPath = field?.GetValue(freshService) as string; + + // Assert + Assert.NotNull(actualPath); + var expectedPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "XplorePlane", + "DebugPanel.config"); + Assert.Equal(expectedPath, actualPath); + } + + /// + /// 测试:配置文件应使用 JSON 格式 + /// **Validates: Requirement 12.5** + /// + [Fact] + public void SaveConfig_UsesJsonFormat() + { + // Arrange + var config = new DebugPanelConfig + { + Window = new WindowConfig { Width = 1200, Height = 800, State = WindowState.Normal }, + EventFilters = new Dictionary { ["Test"] = true } + }; + + // Act + _service.SaveConfig(config); + + // Assert + var json = File.ReadAllText(_configPath); + Assert.StartsWith("{", json.Trim()); + Assert.EndsWith("}", json.Trim()); + Assert.Contains("\"Window\":", json); + Assert.Contains("\"EventFilters\":", json); + } + + /// + /// 测试:默认配置应包含所有 8 种状态类型的过滤器 + /// **Validates: Requirement 12.3** + /// + [Fact] + public void GetDefaultConfig_ContainsAllStateTypeFilters() + { + // Act + var config = _service.LoadConfig(); + + // Assert + Assert.NotNull(config.EventFilters); + Assert.Equal(8, config.EventFilters.Count); + Assert.Contains("MotionState", config.EventFilters.Keys); + Assert.Contains("RaySourceState", config.EventFilters.Keys); + Assert.Contains("DetectorState", config.EventFilters.Keys); + Assert.Contains("SystemState", config.EventFilters.Keys); + Assert.Contains("CameraState", config.EventFilters.Keys); + Assert.Contains("LinkedViewState", config.EventFilters.Keys); + Assert.Contains("RecipeExecutionState", config.EventFilters.Keys); + Assert.Contains("CalibrationMatrix", config.EventFilters.Keys); + } + + /// + /// 测试:默认窗口配置应为 1200×800 像素 + /// **Validates: Requirements 12.1** + /// + [Fact] + public void GetDefaultConfig_HasCorrectWindowSize() + { + // Act + var config = _service.LoadConfig(); + + // Assert + Assert.Equal(1200, config.Window.Width); + Assert.Equal(800, config.Window.Height); + Assert.Equal(WindowState.Normal, config.Window.State); + } + + /// + /// 测试:构造函数应拒绝 null logger + /// + [Fact] + public void Constructor_WithNullLogger_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => new DebugPanelConfigService(null)); + } + } +} diff --git a/XplorePlane.Tests/ViewModels/EventLogViewModelTests.cs b/XplorePlane.Tests/ViewModels/EventLogViewModelTests.cs new file mode 100644 index 0000000..362645b --- /dev/null +++ b/XplorePlane.Tests/ViewModels/EventLogViewModelTests.cs @@ -0,0 +1,212 @@ +using Moq; +using System; +using System.Linq; +using System.Windows.Threading; +using Xunit; +using XP.Common.Logging.Interfaces; +using XplorePlane.Models; +using XplorePlane.Services.AppState; +using XplorePlane.ViewModels.Debug; + +namespace XplorePlane.Tests.ViewModels +{ + /// + /// EventLogViewModel 单元测试 + /// Unit tests for EventLogViewModel + /// + public class EventLogViewModelTests + { + private readonly Mock _mockAppStateService; + private readonly Mock _mockLoggerService; + private readonly Dispatcher _dispatcher; + + public EventLogViewModelTests() + { + _mockAppStateService = new Mock(); + _mockLoggerService = new Mock(); + + // 创建 Dispatcher(需要在 STA 线程上) + // Create Dispatcher (needs to be on STA thread) + _dispatcher = Dispatcher.CurrentDispatcher; + + // 设置 logger mock 返回自身(链式调用) + // Setup logger mock to return itself (method chaining) + _mockLoggerService.Setup(l => l.ForModule()) + .Returns(_mockLoggerService.Object); + } + + [Fact] + public void Constructor_InitializesFilterOptionsWithAllStatesEnabled() + { + // Arrange & Act + var viewModel = new EventLogViewModel( + _mockAppStateService.Object, + _mockLoggerService.Object, + _dispatcher); + + // Assert + Assert.Equal(8, viewModel.FilterOptions.Count); + Assert.True(viewModel.FilterOptions["MotionState"]); + Assert.True(viewModel.FilterOptions["RaySourceState"]); + Assert.True(viewModel.FilterOptions["DetectorState"]); + Assert.True(viewModel.FilterOptions["SystemState"]); + Assert.True(viewModel.FilterOptions["CameraState"]); + Assert.True(viewModel.FilterOptions["LinkedViewState"]); + Assert.True(viewModel.FilterOptions["RecipeExecutionState"]); + Assert.True(viewModel.FilterOptions["CalibrationMatrix"]); + } + + [Fact] + public void ToggleFilterCommand_TogglesFilterState() + { + // Arrange + var viewModel = new EventLogViewModel( + _mockAppStateService.Object, + _mockLoggerService.Object, + _dispatcher); + + var initialState = viewModel.FilterOptions["MotionState"]; + + // Act + viewModel.ToggleFilterCommand.Execute("MotionState"); + + // Assert + Assert.Equal(!initialState, viewModel.FilterOptions["MotionState"]); + } + + [Fact] + public void SelectAllFiltersCommand_EnablesAllFilters() + { + // Arrange + var viewModel = new EventLogViewModel( + _mockAppStateService.Object, + _mockLoggerService.Object, + _dispatcher); + + // 先禁用所有过滤器 + // First disable all filters + viewModel.ClearAllFiltersCommand.Execute(); + + // Act + viewModel.SelectAllFiltersCommand.Execute(); + + // Assert + Assert.All(viewModel.FilterOptions.Values, value => Assert.True(value)); + } + + [Fact] + public void ClearAllFiltersCommand_DisablesAllFilters() + { + // Arrange + var viewModel = new EventLogViewModel( + _mockAppStateService.Object, + _mockLoggerService.Object, + _dispatcher); + + // Act + viewModel.ClearAllFiltersCommand.Execute(); + + // Assert + Assert.All(viewModel.FilterOptions.Values, value => Assert.False(value)); + } + + [Fact] + public void ApplyFilter_FiltersEventLogCorrectly() + { + // Arrange + var viewModel = new EventLogViewModel( + _mockAppStateService.Object, + _mockLoggerService.Object, + _dispatcher); + + // 添加测试数据 + // Add test data + viewModel.EventLog.Add(new EventLogEntry + { + Timestamp = DateTime.Now, + EventType = "MotionStateChanged", + FieldName = "StageX", + OldValue = "0", + NewValue = "100", + Category = "MotionState" + }); + + viewModel.EventLog.Add(new EventLogEntry + { + Timestamp = DateTime.Now, + EventType = "RaySourceStateChanged", + FieldName = "IsOn", + OldValue = "False", + NewValue = "True", + Category = "RaySourceState" + }); + + // 禁用 RaySourceState 过滤器 + // Disable RaySourceState filter + viewModel.FilterOptions["RaySourceState"] = false; + + // Act + viewModel.ToggleFilterCommand.Execute("MotionState"); // 触发过滤器应用 + viewModel.ToggleFilterCommand.Execute("MotionState"); // 恢复状态 + + // Assert + Assert.Single(viewModel.FilteredEventLog); + Assert.Equal("MotionState", viewModel.FilteredEventLog[0].Category); + } + + [Fact] + public void EventLog_LimitsTo1000Records() + { + // Arrange + var viewModel = new EventLogViewModel( + _mockAppStateService.Object, + _mockLoggerService.Object, + _dispatcher); + + // Act - 添加 1001 条记录 + // Act - Add 1001 records + for (int i = 0; i < 1001; i++) + { + viewModel.EventLog.Add(new EventLogEntry + { + Timestamp = DateTime.Now, + EventType = "MotionStateChanged", + FieldName = "StageX", + OldValue = i.ToString(), + NewValue = (i + 1).ToString(), + Category = "MotionState" + }); + } + + // Assert + Assert.Equal(1000, viewModel.EventLog.Count); + } + + [Fact] + public void Dispose_ClearsCollections() + { + // Arrange + var viewModel = new EventLogViewModel( + _mockAppStateService.Object, + _mockLoggerService.Object, + _dispatcher); + + viewModel.EventLog.Add(new EventLogEntry + { + Timestamp = DateTime.Now, + EventType = "MotionStateChanged", + FieldName = "StageX", + OldValue = "0", + NewValue = "100", + Category = "MotionState" + }); + + // Act + viewModel.Dispose(); + + // Assert + Assert.Empty(viewModel.EventLog); + Assert.Empty(viewModel.FilteredEventLog); + } + } +} diff --git a/XplorePlane.Tests/ViewModels/StateDisplayViewModelTests.cs b/XplorePlane.Tests/ViewModels/StateDisplayViewModelTests.cs new file mode 100644 index 0000000..d47c083 --- /dev/null +++ b/XplorePlane.Tests/ViewModels/StateDisplayViewModelTests.cs @@ -0,0 +1,320 @@ +using Moq; +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Threading; +using Xunit; +using XP.Common.Logging.Interfaces; +using XplorePlane.Models; +using XplorePlane.Services.AppState; +using XplorePlane.ViewModels.Debug; + +namespace XplorePlane.Tests.ViewModels +{ + /// + /// 状态显示 ViewModel 单元测试 + /// StateDisplayViewModel unit tests + /// + public class StateDisplayViewModelTests + { + private readonly Mock _mockAppStateService; + private readonly Mock _mockLoggerService; + private readonly Mock _mockModuleLogger; + private readonly Dispatcher _dispatcher; + + public StateDisplayViewModelTests() + { + _mockAppStateService = new Mock(); + _mockLoggerService = new Mock(); + _mockModuleLogger = new Mock(); + + // Setup logger to return module logger + _mockLoggerService + .Setup(l => l.ForModule()) + .Returns(_mockModuleLogger.Object); + + // Create a dispatcher for the current thread + _dispatcher = Dispatcher.CurrentDispatcher; + } + + [Fact] + public void Constructor_InitializesStateTree_WithEightRootNodes() + { + // Arrange & Act + var viewModel = new StateDisplayViewModel( + _mockAppStateService.Object, + _mockLoggerService.Object, + _dispatcher); + + // Assert + Assert.Equal(8, viewModel.StateTree.Count); + Assert.Contains(viewModel.StateTree, n => n.Name == "MotionState"); + Assert.Contains(viewModel.StateTree, n => n.Name == "RaySourceState"); + Assert.Contains(viewModel.StateTree, n => n.Name == "DetectorState"); + Assert.Contains(viewModel.StateTree, n => n.Name == "SystemState"); + Assert.Contains(viewModel.StateTree, n => n.Name == "CameraState"); + Assert.Contains(viewModel.StateTree, n => n.Name == "LinkedViewState"); + Assert.Contains(viewModel.StateTree, n => n.Name == "RecipeExecutionState"); + Assert.Contains(viewModel.StateTree, n => n.Name == "CalibrationMatrix"); + } + + [Fact] + public void Constructor_InitializesMotionStateFields_WithCorrectCount() + { + // Arrange & Act + var viewModel = new StateDisplayViewModel( + _mockAppStateService.Object, + _mockLoggerService.Object, + _dispatcher); + + // Assert + var motionStateNode = viewModel.StateTree.First(n => n.Name == "MotionState"); + Assert.Equal(18, motionStateNode.Children.Count); + } + + [Fact] + public void Initialize_SubscribesToAllStateChangeEvents() + { + // Arrange + var viewModel = new StateDisplayViewModel( + _mockAppStateService.Object, + _mockLoggerService.Object, + _dispatcher); + + // Act + viewModel.Initialize(); + + // Assert + _mockAppStateService.VerifyAdd(s => s.MotionStateChanged += It.IsAny>>(), Times.Once); + _mockAppStateService.VerifyAdd(s => s.RaySourceStateChanged += It.IsAny>>(), Times.Once); + _mockAppStateService.VerifyAdd(s => s.DetectorStateChanged += It.IsAny>>(), Times.Once); + _mockAppStateService.VerifyAdd(s => s.SystemStateChanged += It.IsAny>>(), Times.Once); + _mockAppStateService.VerifyAdd(s => s.CameraStateChanged += It.IsAny>>(), Times.Once); + _mockAppStateService.VerifyAdd(s => s.LinkedViewStateChanged += It.IsAny>>(), Times.Once); + _mockAppStateService.VerifyAdd(s => s.RecipeExecutionStateChanged += It.IsAny>>(), Times.Once); + } + + [Fact] + public void Dispose_UnsubscribesFromAllStateChangeEvents() + { + // Arrange + var viewModel = new StateDisplayViewModel( + _mockAppStateService.Object, + _mockLoggerService.Object, + _dispatcher); + viewModel.Initialize(); + + // Act + viewModel.Dispose(); + + // Assert + _mockAppStateService.VerifyRemove(s => s.MotionStateChanged -= It.IsAny>>(), Times.Once); + _mockAppStateService.VerifyRemove(s => s.RaySourceStateChanged -= It.IsAny>>(), Times.Once); + _mockAppStateService.VerifyRemove(s => s.DetectorStateChanged -= It.IsAny>>(), Times.Once); + _mockAppStateService.VerifyRemove(s => s.SystemStateChanged -= It.IsAny>>(), Times.Once); + _mockAppStateService.VerifyRemove(s => s.CameraStateChanged -= It.IsAny>>(), Times.Once); + _mockAppStateService.VerifyRemove(s => s.LinkedViewStateChanged -= It.IsAny>>(), Times.Once); + _mockAppStateService.VerifyRemove(s => s.RecipeExecutionStateChanged -= It.IsAny>>(), Times.Once); + } + + [Fact] + public void StateChange_UpdatesNodeValue_AndSetsHighlight() + { + // Arrange + var viewModel = new StateDisplayViewModel( + _mockAppStateService.Object, + _mockLoggerService.Object, + _dispatcher); + viewModel.Initialize(); + + var oldState = new RaySourceState(false, 100.0, 5.0); + var newState = new RaySourceState(true, 160.0, 8.0); + + // Act + _mockAppStateService.Raise( + s => s.RaySourceStateChanged += null, + _mockAppStateService.Object, + new StateChangedEventArgs(oldState, newState)); + + // Process dispatcher queue + DoEvents(); + + // Assert + var raySourceNode = viewModel.StateTree.First(n => n.Name == "RaySourceState"); + var isOnNode = raySourceNode.Children.First(n => n.Name == "IsOn"); + var voltageNode = raySourceNode.Children.First(n => n.Name == "Voltage"); + var powerNode = raySourceNode.Children.First(n => n.Name == "Power"); + + Assert.Equal("True", isOnNode.Value); + Assert.True(isOnNode.IsHighlighted); + Assert.Equal("Green", isOnNode.HighlightColor); // Boolean true -> Green + + Assert.Equal("160.00", voltageNode.Value); + Assert.True(voltageNode.IsHighlighted); + Assert.Equal("Green", voltageNode.HighlightColor); // Increase -> Green + + Assert.Equal("8.00", powerNode.Value); + Assert.True(powerNode.IsHighlighted); + Assert.Equal("Green", powerNode.HighlightColor); // Increase -> Green + } + + [Fact] + public void StateChange_BooleanFalse_SetsRedHighlight() + { + // Arrange + var viewModel = new StateDisplayViewModel( + _mockAppStateService.Object, + _mockLoggerService.Object, + _dispatcher); + viewModel.Initialize(); + + var oldState = new RaySourceState(true, 160.0, 8.0); + var newState = new RaySourceState(false, 160.0, 8.0); + + // Act + _mockAppStateService.Raise( + s => s.RaySourceStateChanged += null, + _mockAppStateService.Object, + new StateChangedEventArgs(oldState, newState)); + + // Process dispatcher queue + DoEvents(); + + // Assert + var raySourceNode = viewModel.StateTree.First(n => n.Name == "RaySourceState"); + var isOnNode = raySourceNode.Children.First(n => n.Name == "IsOn"); + + Assert.Equal("False", isOnNode.Value); + Assert.True(isOnNode.IsHighlighted); + Assert.Equal("Red", isOnNode.HighlightColor); // Boolean false -> Red + } + + [Fact] + public void StateChange_NumericDecrease_SetsRedHighlight() + { + // Arrange + var viewModel = new StateDisplayViewModel( + _mockAppStateService.Object, + _mockLoggerService.Object, + _dispatcher); + viewModel.Initialize(); + + var oldState = new RaySourceState(true, 160.0, 8.0); + var newState = new RaySourceState(true, 120.0, 5.0); + + // Act + _mockAppStateService.Raise( + s => s.RaySourceStateChanged += null, + _mockAppStateService.Object, + new StateChangedEventArgs(oldState, newState)); + + // Process dispatcher queue + DoEvents(); + + // Assert + var raySourceNode = viewModel.StateTree.First(n => n.Name == "RaySourceState"); + var voltageNode = raySourceNode.Children.First(n => n.Name == "Voltage"); + var powerNode = raySourceNode.Children.First(n => n.Name == "Power"); + + Assert.Equal("120.00", voltageNode.Value); + Assert.True(voltageNode.IsHighlighted); + Assert.Equal("Red", voltageNode.HighlightColor); // Decrease -> Red + + Assert.Equal("5.00", powerNode.Value); + Assert.True(powerNode.IsHighlighted); + Assert.Equal("Red", powerNode.HighlightColor); // Decrease -> Red + } + + [Fact] + public async Task StateChange_HighlightClearsAfterTwoSeconds() + { + // Arrange + var viewModel = new StateDisplayViewModel( + _mockAppStateService.Object, + _mockLoggerService.Object, + _dispatcher); + viewModel.Initialize(); + + var oldState = new RaySourceState(false, 100.0, 5.0); + var newState = new RaySourceState(true, 160.0, 8.0); + + // Act + _mockAppStateService.Raise( + s => s.RaySourceStateChanged += null, + _mockAppStateService.Object, + new StateChangedEventArgs(oldState, newState)); + + // Process dispatcher queue + DoEvents(); + + var raySourceNode = viewModel.StateTree.First(n => n.Name == "RaySourceState"); + var isOnNode = raySourceNode.Children.First(n => n.Name == "IsOn"); + + // Verify highlight is set + Assert.True(isOnNode.IsHighlighted); + + // Wait for 2.5 seconds (2 seconds delay + buffer) + await Task.Delay(2500); + DoEvents(); + + // Assert - highlight should be cleared + Assert.False(isOnNode.IsHighlighted); + } + + [Fact] + public void StateChange_NoChange_DoesNotSetHighlight() + { + // Arrange + var viewModel = new StateDisplayViewModel( + _mockAppStateService.Object, + _mockLoggerService.Object, + _dispatcher); + viewModel.Initialize(); + + var oldState = new RaySourceState(true, 160.0, 8.0); + var newState = new RaySourceState(true, 160.0, 8.0); + + // Act + _mockAppStateService.Raise( + s => s.RaySourceStateChanged += null, + _mockAppStateService.Object, + new StateChangedEventArgs(oldState, newState)); + + // Process dispatcher queue + DoEvents(); + + // Assert + var raySourceNode = viewModel.StateTree.First(n => n.Name == "RaySourceState"); + var isOnNode = raySourceNode.Children.First(n => n.Name == "IsOn"); + var voltageNode = raySourceNode.Children.First(n => n.Name == "Voltage"); + var powerNode = raySourceNode.Children.First(n => n.Name == "Power"); + + // Values should be updated but not highlighted + Assert.Equal("True", isOnNode.Value); + Assert.False(isOnNode.IsHighlighted); + + Assert.Equal("160.00", voltageNode.Value); + Assert.False(voltageNode.IsHighlighted); + + Assert.Equal("8.00", powerNode.Value); + Assert.False(powerNode.IsHighlighted); + } + + /// + /// 处理 Dispatcher 队列中的所有待处理消息 + /// Process all pending messages in the Dispatcher queue + /// + private void DoEvents() + { + var frame = new DispatcherFrame(); + _dispatcher.BeginInvoke(DispatcherPriority.Background, new DispatcherOperationCallback( + delegate (object f) + { + ((DispatcherFrame)f).Continue = false; + return null; + }), frame); + Dispatcher.PushFrame(frame); + } + } +} diff --git a/XplorePlane/App.xaml.cs b/XplorePlane/App.xaml.cs index 1e4b341..741bc46 100644 --- a/XplorePlane/App.xaml.cs +++ b/XplorePlane/App.xaml.cs @@ -36,6 +36,7 @@ using XplorePlane.Services; using XplorePlane.Services.AppState; using XplorePlane.Services.Camera; using XplorePlane.Services.Cnc; +using XplorePlane.Services.Debug; using XplorePlane.Services.InspectionResults; using XplorePlane.Services.MainViewport; using XplorePlane.Services.Matrix; @@ -45,8 +46,10 @@ using XplorePlane.Services.Recording; using XplorePlane.Services.Storage; using XplorePlane.ViewModels; using XplorePlane.ViewModels.Cnc; +using XplorePlane.ViewModels.Debug; using XplorePlane.Views; using XplorePlane.Views.Cnc; +using XplorePlane.Views.Debug; namespace XplorePlane { @@ -438,6 +441,9 @@ namespace XplorePlane // 注册全局状态服务(单例) containerRegistry.RegisterSingleton(); containerRegistry.RegisterSingleton(); + containerRegistry.RegisterSingleton(); + containerRegistry.Register(); + containerRegistry.Register(); // 注册检测配方服务(单例) containerRegistry.RegisterSingleton(); diff --git a/XplorePlane/Converters/HighlightColorBrushConverter.cs b/XplorePlane/Converters/HighlightColorBrushConverter.cs new file mode 100644 index 0000000..ad62961 --- /dev/null +++ b/XplorePlane/Converters/HighlightColorBrushConverter.cs @@ -0,0 +1,26 @@ +using System; +using System.Globalization; +using System.Windows.Data; +using System.Windows.Media; + +namespace XplorePlane.Converters +{ + public class HighlightColorBrushConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return value switch + { + "Green" => Brushes.LightGreen, + "Red" => Brushes.LightCoral, + "Yellow" => Brushes.Khaki, + _ => Brushes.Transparent + }; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } + } +} diff --git a/XplorePlane/Converters/TimestampFormatConverter.cs b/XplorePlane/Converters/TimestampFormatConverter.cs new file mode 100644 index 0000000..724c927 --- /dev/null +++ b/XplorePlane/Converters/TimestampFormatConverter.cs @@ -0,0 +1,24 @@ +using System; +using System.Globalization; +using System.Windows.Data; + +namespace XplorePlane.Converters +{ + public class TimestampFormatConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is DateTime timestamp) + { + return timestamp.ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture); + } + + return string.Empty; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } + } +} diff --git a/XplorePlane/Converters/ValueFormatConverter.cs b/XplorePlane/Converters/ValueFormatConverter.cs new file mode 100644 index 0000000..cc28010 --- /dev/null +++ b/XplorePlane/Converters/ValueFormatConverter.cs @@ -0,0 +1,20 @@ +using System; +using System.Globalization; +using System.Windows.Data; +using XplorePlane.ViewModels.Debug; + +namespace XplorePlane.Converters +{ + public class ValueFormatConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return DebugPanelStateFormatter.FormatValue(value); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } + } +} diff --git a/XplorePlane/Models/DebugPanelModels.cs b/XplorePlane/Models/DebugPanelModels.cs new file mode 100644 index 0000000..916e145 --- /dev/null +++ b/XplorePlane/Models/DebugPanelModels.cs @@ -0,0 +1,208 @@ +using Prism.Mvvm; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Windows; + +namespace XplorePlane.Models +{ + /// + /// 状态树节点 ViewModel(用于 TreeView 绑定) + /// State tree node ViewModel for TreeView binding + /// + public class StateNodeViewModel : BindableBase + { + private string _value; + private bool _isHighlighted; + private string _highlightColor; + + /// 字段名称 | Field name + public string Name { get; set; } + + /// 字段值(格式化字符串)| Field value (formatted string) + public string Value + { + get => _value; + set => SetProperty(ref _value, value); + } + + /// 状态类别(MotionState, RaySourceState 等)| State category + public string Category { get; set; } + + /// 是否高亮 | Whether highlighted + public bool IsHighlighted + { + get => _isHighlighted; + set => SetProperty(ref _isHighlighted, value); + } + + /// 高亮颜色(Green/Red/Yellow)| Highlight color + public string HighlightColor + { + get => _highlightColor; + set => SetProperty(ref _highlightColor, value); + } + + /// 子节点集合 | Child nodes collection + public ObservableCollection Children { get; set; } = new ObservableCollection(); + } + + /// + /// 事件日志条目(用于 GridView 绑定) + /// Event log entry for GridView binding + /// + public class EventLogEntry + { + /// 时间戳 | Timestamp + public DateTime Timestamp { get; set; } + + /// 事件类型(MotionStateChanged, RaySourceStateChanged 等)| Event type + public string EventType { get; set; } + + /// 字段名称 | Field name + public string FieldName { get; set; } + + /// 旧值 | Old value + public string OldValue { get; set; } + + /// 新值 | New value + public string NewValue { get; set; } + + /// 状态类别(用于过滤)| State category (for filtering) + public string Category { get; set; } + } + + /// + /// 状态快照 + /// State snapshot + /// + public class StateSnapshot + { + /// 快照唯一标识 | Snapshot unique identifier + public Guid Id { get; set; } + + /// 捕获时间戳 | Capture timestamp + public DateTime Timestamp { get; set; } + + /// 运动控制状态 | Motion state + public MotionState MotionState { get; set; } + + /// 射线源状态 | Ray source state + public RaySourceState RaySourceState { get; set; } + + /// 探测器状态 | Detector state + public DetectorState DetectorState { get; set; } + + /// 系统状态 | System state + public SystemState SystemState { get; set; } + + /// 相机状态 | Camera state + public CameraState CameraState { get; set; } + + /// 联动视图状态 | Linked view state + public LinkedViewState LinkedViewState { get; set; } + + /// 配方执行状态 | Recipe execution state + public RecipeExecutionState RecipeExecutionState { get; set; } + + /// 标定矩阵 | Calibration matrix + public CalibrationMatrix CalibrationMatrix { get; set; } + } + + /// + /// 状态差异(用于快照对比) + /// State difference for snapshot comparison + /// + public class StateDifference + { + /// 状态类别 | State category + public string Category { get; set; } + + /// 字段名称 | Field name + public string FieldName { get; set; } + + /// 快照1的值 | Value from snapshot 1 + public string Value1 { get; set; } + + /// 快照2的值 | Value from snapshot 2 + public string Value2 { get; set; } + } + + /// + /// 性能指标(用于性能监控) + /// Performance metric for performance monitoring + /// + public class PerformanceMetric : BindableBase + { + private double _eventsPerSecond; + private double _averageLatency; + + /// 状态类型 | State type + public string StateType { get; set; } + + /// 事件频率(事件/秒)| Events per second + public double EventsPerSecond + { + get => _eventsPerSecond; + set => SetProperty(ref _eventsPerSecond, value); + } + + /// 平均延迟(毫秒)| Average latency (milliseconds) + public double AverageLatency + { + get => _averageLatency; + set => SetProperty(ref _averageLatency, value); + } + } + + /// + /// 趋势数据点(用于 Chart 绑定) + /// Trend data point for Chart binding + /// + public class TrendDataPoint + { + /// 时间戳 | Timestamp + public DateTime Timestamp { get; set; } + + /// 数值 | Value + public double Value { get; set; } + } + + /// + /// 调试面板配置 + /// Debug panel configuration + /// + public class DebugPanelConfig + { + /// 窗口配置 | Window configuration + public WindowConfig Window { get; set; } + + /// 事件过滤器设置 | Event filter settings + public Dictionary EventFilters { get; set; } + + /// Telerik RadDocking 布局 XML | Telerik RadDocking layout XML + public string DockingLayout { get; set; } + } + + /// + /// 窗口配置 + /// Window configuration + /// + public class WindowConfig + { + /// 窗口左边距 | Window left position + public double Left { get; set; } + + /// 窗口顶边距 | Window top position + public double Top { get; set; } + + /// 窗口宽度 | Window width + public double Width { get; set; } + + /// 窗口高度 | Window height + public double Height { get; set; } + + /// 窗口状态 | Window state + public WindowState State { get; set; } + } +} diff --git a/XplorePlane/ViewModels/Main/MainViewModel.cs b/XplorePlane/ViewModels/Main/MainViewModel.cs index 4f1703e..7ae90db 100644 --- a/XplorePlane/ViewModels/Main/MainViewModel.cs +++ b/XplorePlane/ViewModels/Main/MainViewModel.cs @@ -17,8 +17,10 @@ using XplorePlane.Services.MainViewport; using XplorePlane.Services.Recording; using XplorePlane.Services.Storage; using XplorePlane.ViewModels.Cnc; +using XplorePlane.ViewModels.Debug; using XplorePlane.Views; using XplorePlane.Views.Cnc; +using XplorePlane.Views.Debug; using XP.Common.Logging.Interfaces; using XP.Common.GeneralForm.Views; using XP.Common.PdfViewer.Interfaces; @@ -104,6 +106,7 @@ namespace XplorePlane.ViewModels public DelegateCommand OpenUserManualCommand { get; } public DelegateCommand OpenCameraSettingsCommand { get; } public DelegateCommand OpenSettingsCommand { get; } + public DelegateCommand OpenDebugPanelCommand { get; } public DelegateCommand BrowseDataRootPathCommand { get; } public DelegateCommand ResetDataRootPathCommand { get; } public DelegateCommand SaveDataRootPathCommand { get; } @@ -228,6 +231,7 @@ namespace XplorePlane.ViewModels private Window _toolboxWindow; private Window _raySourceConfigWindow; private Window _inspectionReportViewerWindow; + private Window _debugPanelWindow; private object _imagePanelContent; private GridLength _viewportPanelWidth = new(1, GridUnitType.Star); private GridLength _imagePanelWidth = new(320); @@ -303,6 +307,7 @@ namespace XplorePlane.ViewModels OpenUserManualCommand = new DelegateCommand(ExecuteOpenUserManual); OpenCameraSettingsCommand = new DelegateCommand(ExecuteOpenCameraSettings); OpenSettingsCommand = new DelegateCommand(ExecuteOpenSettings); + OpenDebugPanelCommand = new DelegateCommand(ExecuteOpenDebugPanel); BrowseDataRootPathCommand = new DelegateCommand(ExecuteBrowseDataRootPath); ResetDataRootPathCommand = new DelegateCommand(ExecuteResetDataRootPath); SaveDataRootPathCommand = new DelegateCommand(ExecuteSaveDataRootPath); @@ -569,6 +574,27 @@ namespace XplorePlane.ViewModels } } + private void ExecuteOpenDebugPanel() + { + try + { + ShowOrActivate(_debugPanelWindow, w => _debugPanelWindow = w, + () => + { + var viewModel = _containerProvider.Resolve(); + var window = _containerProvider.Resolve(); + window.DataContext = viewModel; + return window; + }, "Debug Panel"); + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to open debug panel"); + MessageBox.Show($"Failed to open debug panel: {ex.Message}", + "Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + private void ExecuteBrowseDataRootPath() { try diff --git a/XplorePlane/Views/Main/MainWindow.xaml b/XplorePlane/Views/Main/MainWindow.xaml index 1bc835a..6ff2a08 100644 --- a/XplorePlane/Views/Main/MainWindow.xaml +++ b/XplorePlane/Views/Main/MainWindow.xaml @@ -541,6 +541,20 @@ Command="{Binding OpenRealTimeLogViewerCommand}" Text="查看日志" /> + + + + + + + + @@ -648,4 +662,4 @@ - \ No newline at end of file +