502 lines
22 KiB
C#
502 lines
22 KiB
C#
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);
|
||
}
|
||
}
|