Files
XplorePlane/XplorePlane.Tests/Services/DebugPanelIntegrationTests.cs
T
2026-05-18 15:32:00 +08:00

502 lines
22 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
{
/// <summary>
/// 调试面板集成测试
/// 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
/// </summary>
public class DebugPanelIntegrationTests : IDisposable
{
private readonly Mock<IAppStateService> _mockAppStateService;
private readonly Mock<ILoggerService> _mockLoggerService;
private readonly Mock<ILoggerService> _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<IAppStateService>();
_mockLoggerService = new Mock<ILoggerService>();
_mockModuleLogger = new Mock<ILoggerService>();
// Wire up all ForModule<T> calls to return the module logger mock
_mockLoggerService.Setup(l => l.ForModule<DebugPanelViewModel>()).Returns(_mockModuleLogger.Object);
_mockLoggerService.Setup(l => l.ForModule<StateDisplayViewModel>()).Returns(_mockModuleLogger.Object);
_mockLoggerService.Setup(l => l.ForModule<EventLogViewModel>()).Returns(_mockModuleLogger.Object);
_mockLoggerService.Setup(l => l.ForModule<SnapshotManagerViewModel>()).Returns(_mockModuleLogger.Object);
_mockLoggerService.Setup(l => l.ForModule<PerformanceMonitorViewModel>()).Returns(_mockModuleLogger.Object);
_mockLoggerService.Setup(l => l.ForModule<DebugPanelConfigService>()).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 ──────────────────────────────────────
/// <summary>
/// 创建 DebugPanelViewModel 后,所有子 ViewModel 应被初始化
/// After creating DebugPanelViewModel, all child ViewModels should be initialised
/// Validates: Requirements 1.1, 1.5
/// </summary>
[Fact]
public void CreateDebugPanelViewModel_InitializesAllChildViewModels()
{
var vm = CreateViewModel();
Assert.NotNull(vm.StateDisplay);
Assert.NotNull(vm.EventLog);
Assert.NotNull(vm.SnapshotManager);
Assert.NotNull(vm.PerformanceMonitor);
}
/// <summary>
/// 调用 Initialize 后,各子 ViewModel 应各自订阅状态变更事件(每个事件共 3 次)
/// After calling Initialize, each child VM subscribes once — 3 total per event
/// Validates: Requirement 1.5
/// </summary>
[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<EventHandler<StateChangedEventArgs<MotionState>>>(),
Times.Exactly(3));
_mockAppStateService.VerifyAdd(
s => s.RaySourceStateChanged += It.IsAny<EventHandler<StateChangedEventArgs<RaySourceState>>>(),
Times.Exactly(3));
}
/// <summary>
/// 重复调用 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
/// </summary>
[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<EventHandler<StateChangedEventArgs<MotionState>>>(),
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<EventHandler<StateChangedEventArgs<MotionState>>>(),
Times.Exactly(3),
"After second Initialize(), subscription count must not increase");
}
// ─── Singleton window behaviour ───────────────────────────────────────────
/// <summary>
/// 同一 ViewModel 实例不应被初始化两次(单例行为)
/// The same ViewModel instance should not be initialised twice (singleton behaviour)
/// Validates: Requirement 1.2
/// </summary>
[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<EventHandler<StateChangedEventArgs<MotionState>>>(),
Times.Exactly(3),
"MotionStateChanged should be subscribed exactly 3 times (once per child VM) even if Initialize is called twice");
}
// ─── Dispose / resource cleanup ───────────────────────────────────────────
/// <summary>
/// Dispose 后,子 ViewModel 的集合应被清空
/// After Dispose, child ViewModel collections should be cleared
/// Validates: Requirement 1.3
/// </summary>
[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);
}
/// <summary>
/// Dispose 后再次调用 Dispose 不应抛出异常
/// Calling Dispose twice should not throw
/// Validates: Requirement 1.3
/// </summary>
[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 ─────────────────────────────────────────
/// <summary>
/// 创建 ViewModel 时应从配置文件加载偏好设置
/// Creating the ViewModel should load preferences from the config file
/// Validates: Requirement 12.6
/// </summary>
[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<string, bool>
{
["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");
}
/// <summary>
/// Dispose 时应将当前过滤器设置保存到配置文件
/// On Dispose, current filter settings should be saved to the config file
/// Validates: Requirements 12.1-12.3
/// </summary>
[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");
}
/// <summary>
/// 当配置文件不存在时,ViewModel 应使用默认配置
/// When no config file exists, ViewModel should use default configuration
/// Validates: Requirement 12.7
/// </summary>
[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);
}
/// <summary>
/// ResetLayoutCommand 应将所有过滤器恢复为启用状态
/// ResetLayoutCommand should restore all filters to enabled
/// Validates: Requirement 12.2
/// </summary>
[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));
}
/// <summary>
/// SaveLayoutCommand 应将当前过滤器状态持久化到配置文件
/// SaveLayoutCommand should persist current filter state to the config file
/// Validates: Requirements 12.1-12.3
/// </summary>
[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 ──────────────────────────────────────────────
/// <summary>
/// 状态变更事件应传播到 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.
/// </summary>
[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<RaySourceState>(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");
}
/// <summary>
/// 状态变更事件应传播到 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.
/// </summary>
[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<RaySourceState>(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"]);
}
/// <summary>
/// 状态变更事件应传播到 PerformanceMonitor 子 ViewModel(验证事件订阅已建立)
/// State-change events should be routed to PerformanceMonitorViewModel (verify subscription is wired)
/// Validates: Requirements 1.5, 9.1
/// </summary>
[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<MotionState>(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 ─────────────────────────────────────────
/// <summary>
/// 捕获快照后,快照应包含所有 8 种状态对象的当前值
/// After capturing a snapshot, it should contain current values of all 8 state objects
/// Validates: Requirements 7.2-7.4
/// </summary>
[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);
}
}