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);
}
}