using Moq; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; using System.Windows; using Xunit; using XP.Common.Logging.Interfaces; using XplorePlane.Models; using XplorePlane.Services.AppState; using XplorePlane.Services.Debug; using XplorePlane.ViewModels.Debug; namespace XplorePlane.Tests.Services { /// /// 调试面板集成测试 /// Integration tests for the Debug Panel feature /// /// Tests the full lifecycle of DebugPanelViewModel including: /// - Opening and initialising the panel /// - Singleton window behaviour (single instance) /// - Configuration load and save round-trip /// /// Validates: Requirements 1.1-1.5, 12.1-12.7 /// public class DebugPanelIntegrationTests : IDisposable { private readonly Mock _mockAppStateService; private readonly Mock _mockLoggerService; private readonly Mock _mockModuleLogger; private readonly string _tempConfigDir; private readonly string _tempConfigPath; private readonly DebugPanelConfigService _realConfigService; public DebugPanelIntegrationTests() { // Application.Current may already exist (created by another test class in the same AppDomain). // Attempting to create a second instance throws InvalidOperationException, so we skip it. // The Dispatcher is available regardless — WPF initialises it on the first STA thread access. _mockAppStateService = new Mock(); _mockLoggerService = new Mock(); _mockModuleLogger = new Mock(); // Wire up all ForModule calls to return the module logger mock _mockLoggerService.Setup(l => l.ForModule()).Returns(_mockModuleLogger.Object); _mockLoggerService.Setup(l => l.ForModule()).Returns(_mockModuleLogger.Object); _mockLoggerService.Setup(l => l.ForModule()).Returns(_mockModuleLogger.Object); _mockLoggerService.Setup(l => l.ForModule()).Returns(_mockModuleLogger.Object); _mockLoggerService.Setup(l => l.ForModule()).Returns(_mockModuleLogger.Object); _mockLoggerService.Setup(l => l.ForModule()).Returns(_mockModuleLogger.Object); // Use a temp directory so tests don't pollute %AppData% _tempConfigDir = Path.Combine(Path.GetTempPath(), "XplorePlaneIntegrationTests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_tempConfigDir); _tempConfigPath = Path.Combine(_tempConfigDir, "DebugPanel.config"); _realConfigService = new DebugPanelConfigService(_mockLoggerService.Object); // Redirect the config path to the temp directory via reflection var configPathField = typeof(DebugPanelConfigService) .GetField("_configPath", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); configPathField?.SetValue(_realConfigService, _tempConfigPath); } public void Dispose() { if (Directory.Exists(_tempConfigDir)) { Directory.Delete(_tempConfigDir, recursive: true); } } // ─── Panel opening / initialisation ────────────────────────────────────── /// /// 创建 DebugPanelViewModel 后,所有子 ViewModel 应被初始化 /// After creating DebugPanelViewModel, all child ViewModels should be initialised /// Validates: Requirements 1.1, 1.5 /// [Fact] public void CreateDebugPanelViewModel_InitializesAllChildViewModels() { var vm = CreateViewModel(); Assert.NotNull(vm.StateDisplay); Assert.NotNull(vm.EventLog); Assert.NotNull(vm.SnapshotManager); Assert.NotNull(vm.PerformanceMonitor); } /// /// 调用 Initialize 后,各子 ViewModel 应各自订阅状态变更事件(每个事件共 3 次) /// After calling Initialize, each child VM subscribes once — 3 total per event /// Validates: Requirement 1.5 /// [Fact] public void Initialize_SubscribesAllChildViewModels_ToStateChangeEvents() { var vm = CreateViewModel(); vm.Initialize(); // 3 child VMs (StateDisplay, EventLog, PerformanceMonitor) each subscribe once _mockAppStateService.VerifyAdd( s => s.MotionStateChanged += It.IsAny>>(), Times.Exactly(3)); _mockAppStateService.VerifyAdd( s => s.RaySourceStateChanged += It.IsAny>>(), Times.Exactly(3)); } /// /// 重复调用 Initialize 不应重复订阅事件 /// Calling Initialize twice should not double-subscribe events. /// Each child VM (StateDisplay, EventLog, PerformanceMonitor) subscribes once, /// so after the first Initialize() there are 3 subscriptions total. /// The second Initialize() must be a no-op — the count must not grow. /// Validates: Requirement 1.5 /// [Fact] public void Initialize_CalledTwice_DoesNotDoubleSubscribe() { var vm = CreateViewModel(); // First call — 3 child VMs each subscribe once → 3 total vm.Initialize(); _mockAppStateService.VerifyAdd( s => s.MotionStateChanged += It.IsAny>>(), Times.Exactly(3), "After first Initialize(), each of the 3 child VMs should have subscribed once"); // Second call — must be a no-op; count stays at 3 vm.Initialize(); _mockAppStateService.VerifyAdd( s => s.MotionStateChanged += It.IsAny>>(), Times.Exactly(3), "After second Initialize(), subscription count must not increase"); } // ─── Singleton window behaviour ─────────────────────────────────────────── /// /// 同一 ViewModel 实例不应被初始化两次(单例行为) /// The same ViewModel instance should not be initialised twice (singleton behaviour) /// Validates: Requirement 1.2 /// [Fact] public void SingletonBehaviour_SameViewModelInstance_InitializedOnce() { var vm = CreateViewModel(); vm.Initialize(); vm.Initialize(); // Second call should be a no-op // After first Initialize(): 3 child VMs × 1 subscription = 3 total. // After second Initialize(): still 3 — the guard prevents re-subscription. _mockAppStateService.VerifyAdd( s => s.MotionStateChanged += It.IsAny>>(), Times.Exactly(3), "MotionStateChanged should be subscribed exactly 3 times (once per child VM) even if Initialize is called twice"); } // ─── Dispose / resource cleanup ─────────────────────────────────────────── /// /// Dispose 后,子 ViewModel 的集合应被清空 /// After Dispose, child ViewModel collections should be cleared /// Validates: Requirement 1.3 /// [Fact] public void Dispose_ClearsChildViewModelCollections() { var vm = CreateViewModel(); vm.Initialize(); vm.Dispose(); Assert.Empty(vm.EventLog.EventLog); Assert.Empty(vm.PerformanceMonitor.Metrics); Assert.Empty(vm.PerformanceMonitor.TrendData); } /// /// Dispose 后再次调用 Dispose 不应抛出异常 /// Calling Dispose twice should not throw /// Validates: Requirement 1.3 /// [Fact] public void Dispose_CalledTwice_DoesNotThrow() { var vm = CreateViewModel(); vm.Initialize(); var ex = Record.Exception(() => { vm.Dispose(); vm.Dispose(); }); Assert.Null(ex); } // ─── Configuration load and save ───────────────────────────────────────── /// /// 创建 ViewModel 时应从配置文件加载偏好设置 /// Creating the ViewModel should load preferences from the config file /// Validates: Requirement 12.6 /// [Fact] public void CreateViewModel_LoadsConfigFromFile() { // Arrange: pre-write a config with custom filter settings var savedConfig = new DebugPanelConfig { Window = new WindowConfig { Width = 1400, Height = 900, State = WindowState.Normal }, EventFilters = new Dictionary { ["MotionState"] = false, ["RaySourceState"] = true, ["DetectorState"] = true, ["SystemState"] = true, ["CameraState"] = true, ["LinkedViewState"] = true, ["RecipeExecutionState"] = true, ["CalibrationMatrix"] = true } }; _realConfigService.SaveConfig(savedConfig); // Act var vm = CreateViewModel(); vm.Initialize(); // Assert: the filter for MotionState should be false as saved Assert.False(vm.EventLog.FilterOptions["MotionState"], "MotionState filter should be false as saved in config"); Assert.True(vm.EventLog.FilterOptions["RaySourceState"], "RaySourceState filter should be true as saved in config"); } /// /// Dispose 时应将当前过滤器设置保存到配置文件 /// On Dispose, current filter settings should be saved to the config file /// Validates: Requirements 12.1-12.3 /// [Fact] public void Dispose_SavesFilterSettingsToConfig() { var vm = CreateViewModel(); vm.Initialize(); // Change a filter setting vm.EventLog.FilterOptions["MotionState"] = false; // Act: Dispose triggers SaveLayout which calls SaveConfig vm.Dispose(); // Assert: reload config and verify the filter was persisted var reloadedConfig = _realConfigService.LoadConfig(); Assert.NotNull(reloadedConfig.EventFilters); Assert.False(reloadedConfig.EventFilters.TryGetValue("MotionState", out var motionFilter) && motionFilter, "MotionState filter should have been saved as false"); } /// /// 当配置文件不存在时,ViewModel 应使用默认配置 /// When no config file exists, ViewModel should use default configuration /// Validates: Requirement 12.7 /// [Fact] public void CreateViewModel_WhenNoConfigFile_UsesDefaultConfig() { // Ensure no config file exists if (File.Exists(_tempConfigPath)) { File.Delete(_tempConfigPath); } var vm = CreateViewModel(); vm.Initialize(); // Default config enables all filters Assert.True(vm.EventLog.FilterOptions["MotionState"], "Default config should enable MotionState filter"); Assert.True(vm.EventLog.FilterOptions["RaySourceState"], "Default config should enable RaySourceState filter"); Assert.NotNull(vm.CurrentConfig); Assert.Equal(1200, vm.CurrentConfig.Window.Width); Assert.Equal(800, vm.CurrentConfig.Window.Height); } /// /// ResetLayoutCommand 应将所有过滤器恢复为启用状态 /// ResetLayoutCommand should restore all filters to enabled /// Validates: Requirement 12.2 /// [Fact] public void ResetLayoutCommand_RestoresAllFiltersToEnabled() { var vm = CreateViewModel(); vm.Initialize(); // Disable some filters vm.EventLog.FilterOptions["MotionState"] = false; vm.EventLog.FilterOptions["RaySourceState"] = false; // Act vm.ResetLayoutCommand.Execute(); // Assert: all filters should be re-enabled Assert.All(vm.EventLog.FilterOptions.Values, enabled => Assert.True(enabled)); } /// /// SaveLayoutCommand 应将当前过滤器状态持久化到配置文件 /// SaveLayoutCommand should persist current filter state to the config file /// Validates: Requirements 12.1-12.3 /// [Fact] public void SaveLayoutCommand_PersistsCurrentFilterState() { var vm = CreateViewModel(); vm.Initialize(); // Change a filter vm.EventLog.FilterOptions["DetectorState"] = false; // Act vm.SaveLayoutCommand.Execute(); // Assert: reload and verify var reloadedConfig = _realConfigService.LoadConfig(); Assert.NotNull(reloadedConfig.EventFilters); Assert.True(reloadedConfig.EventFilters.ContainsKey("DetectorState"), "DetectorState key should be present in saved config"); Assert.False(reloadedConfig.EventFilters["DetectorState"], "DetectorState filter should have been saved as false"); } // ─── State event propagation ────────────────────────────────────────────── /// /// 状态变更事件应传播到 StateDisplay 子 ViewModel(验证事件订阅已建立) /// State-change events should be routed to StateDisplayViewModel (verify subscription is wired) /// Validates: Requirements 1.5, 2.9 /// Note: UI updates happen via Dispatcher.BeginInvoke which requires a running message pump. /// This test verifies the subscription is established; the StateDisplayViewModelTests cover /// the actual update logic with a proper Dispatcher. /// [Fact] public void StateChangeEvent_PropagatesTo_StateDisplayViewModel() { var vm = CreateViewModel(); vm.Initialize(); // Verify the subscription is wired: raising the event should not throw var ex = Record.Exception(() => { var oldState = new RaySourceState(false, 100.0, 5.0); var newState = new RaySourceState(true, 160.0, 8.0); _mockAppStateService.Raise( s => s.RaySourceStateChanged += null, _mockAppStateService.Object, new StateChangedEventArgs(oldState, newState)); }); Assert.Null(ex); // Verify the StateTree is initialised with the expected root nodes Assert.Equal(8, vm.StateDisplay.StateTree.Count); Assert.Contains(vm.StateDisplay.StateTree, n => n.Name == "RaySourceState"); } /// /// 状态变更事件应传播到 EventLog 子 ViewModel(验证事件订阅已建立) /// State-change events should be routed to EventLogViewModel (verify subscription is wired) /// Validates: Requirements 1.5, 4.2 /// Note: EventLog entries are added via Dispatcher.BeginInvoke. This test verifies the /// subscription is established and no exception is thrown; the EventLogViewModelTests /// cover the actual entry-addition logic with a proper Dispatcher. /// [Fact] public void StateChangeEvent_PropagatesTo_EventLogViewModel() { var vm = CreateViewModel(); vm.Initialize(); // Verify the subscription is wired: raising the event should not throw var ex = Record.Exception(() => { var oldState = new RaySourceState(false, 100.0, 5.0); var newState = new RaySourceState(true, 160.0, 8.0); _mockAppStateService.Raise( s => s.RaySourceStateChanged += null, _mockAppStateService.Object, new StateChangedEventArgs(oldState, newState)); }); Assert.Null(ex); // Verify filter options are initialised (proves EventLogViewModel is wired up) Assert.True(vm.EventLog.FilterOptions.ContainsKey("RaySourceState")); Assert.True(vm.EventLog.FilterOptions["RaySourceState"]); } /// /// 状态变更事件应传播到 PerformanceMonitor 子 ViewModel(验证事件订阅已建立) /// State-change events should be routed to PerformanceMonitorViewModel (verify subscription is wired) /// Validates: Requirements 1.5, 9.1 /// [Fact] public void StateChangeEvent_PropagatesTo_PerformanceMonitorViewModel() { var vm = CreateViewModel(); vm.Initialize(); // Verify the subscription is wired: raising the event should not throw, // and the event is recorded synchronously into the ConcurrentQueue. var ex = Record.Exception(() => { var oldState = new MotionState(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0); var newState = new MotionState(1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0); _mockAppStateService.Raise( s => s.MotionStateChanged += null, _mockAppStateService.Object, new StateChangedEventArgs(oldState, newState)); }); Assert.Null(ex); // Verify the MotionState metric exists (proves PerformanceMonitorViewModel is wired up) var motionMetric = vm.PerformanceMonitor.Metrics.FirstOrDefault(m => m.StateType == "MotionState"); Assert.NotNull(motionMetric); } // ─── Snapshot capture integration ───────────────────────────────────────── /// /// 捕获快照后,快照应包含所有 8 种状态对象的当前值 /// After capturing a snapshot, it should contain current values of all 8 state objects /// Validates: Requirements 7.2-7.4 /// [Fact] public void CaptureSnapshot_IncludesAllStateObjects() { // Arrange: set up mock state values var motionState = new MotionState(10, 20, 30, 40, 5, 1000, 1, 2, 3, 4, 0.5, 50, 0, 0, 500, 2, 0, 0); var raySourceState = new RaySourceState(true, 160.0, 8.0); var detectorState = new DetectorState(true, false, 30.0, "1920x1080"); var systemState = new SystemState(OperationMode.Idle, false, string.Empty); var cameraState = new CameraState(true, false, null, 1920, 1080, 30.0); var linkedViewState = new LinkedViewState(new PhysicalPosition(0, 0, 0), false, DateTime.Now); var recipeState = new RecipeExecutionState(0, 5, RecipeExecutionStatus.Idle, "TestRecipe"); var calibMatrix = new CalibrationMatrix(1, 0, 0, 0, 1, 0, 0, 0, 1); _mockAppStateService.Setup(s => s.MotionState).Returns(motionState); _mockAppStateService.Setup(s => s.RaySourceState).Returns(raySourceState); _mockAppStateService.Setup(s => s.DetectorState).Returns(detectorState); _mockAppStateService.Setup(s => s.SystemState).Returns(systemState); _mockAppStateService.Setup(s => s.CameraState).Returns(cameraState); _mockAppStateService.Setup(s => s.LinkedViewState).Returns(linkedViewState); _mockAppStateService.Setup(s => s.RecipeExecutionState).Returns(recipeState); _mockAppStateService.Setup(s => s.CalibrationMatrix).Returns(calibMatrix); var vm = CreateViewModel(); vm.Initialize(); // Act vm.SnapshotManager.CaptureSnapshot(); // Assert Assert.Single(vm.SnapshotManager.Snapshots); var snapshot = vm.SnapshotManager.Snapshots[0].Snapshot; Assert.NotNull(snapshot); Assert.Equal(motionState, snapshot.MotionState); Assert.Equal(raySourceState, snapshot.RaySourceState); Assert.Equal(detectorState, snapshot.DetectorState); Assert.Equal(systemState, snapshot.SystemState); Assert.Equal(cameraState, snapshot.CameraState); Assert.Equal(linkedViewState, snapshot.LinkedViewState); Assert.Equal(recipeState, snapshot.RecipeExecutionState); Assert.Equal(calibMatrix, snapshot.CalibrationMatrix); } // ─── Helpers ───────────────────────────────────────────────────────────── private DebugPanelViewModel CreateViewModel() => new DebugPanelViewModel( _mockAppStateService.Object, _mockLoggerService.Object, _realConfigService); } }