500 lines
21 KiB
C#
500 lines
21 KiB
C#
using Moq;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Threading.Tasks;
|
|
using System.Windows;
|
|
using System.Windows.Threading;
|
|
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;
|
|
private readonly Dispatcher _dispatcher;
|
|
|
|
public DebugPanelIntegrationTests()
|
|
{
|
|
// Ensure a WPF Application exists (required for Dispatcher.CurrentDispatcher in WPF context)
|
|
if (Application.Current == null)
|
|
{
|
|
new Application();
|
|
}
|
|
|
|
_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);
|
|
|
|
_dispatcher = Dispatcher.CurrentDispatcher;
|
|
}
|
|
|
|
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 后,StateDisplay 应订阅状态变更事件
|
|
/// After calling Initialize, StateDisplay should subscribe to state-change events
|
|
/// Validates: Requirement 1.5
|
|
/// </summary>
|
|
[Fact]
|
|
public void Initialize_SubscribesAllChildViewModels_ToStateChangeEvents()
|
|
{
|
|
var vm = CreateViewModel();
|
|
|
|
vm.Initialize();
|
|
|
|
// StateDisplay subscribes to 7 events (CalibrationMatrix has no Changed event)
|
|
_mockAppStateService.VerifyAdd(
|
|
s => s.MotionStateChanged += It.IsAny<EventHandler<StateChangedEventArgs<MotionState>>>(),
|
|
Times.AtLeastOnce);
|
|
_mockAppStateService.VerifyAdd(
|
|
s => s.RaySourceStateChanged += It.IsAny<EventHandler<StateChangedEventArgs<RaySourceState>>>(),
|
|
Times.AtLeastOnce);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 重复调用 Initialize 不应重复订阅事件
|
|
/// Calling Initialize twice should not double-subscribe events
|
|
/// Validates: Requirement 1.5
|
|
/// </summary>
|
|
[Fact]
|
|
public void Initialize_CalledTwice_DoesNotDoubleSubscribe()
|
|
{
|
|
var vm = CreateViewModel();
|
|
|
|
vm.Initialize();
|
|
vm.Initialize();
|
|
|
|
// Each child VM guards against double-init; total subscriptions should be exactly once per event
|
|
_mockAppStateService.VerifyAdd(
|
|
s => s.MotionStateChanged += It.IsAny<EventHandler<StateChangedEventArgs<MotionState>>>(),
|
|
Times.Once);
|
|
}
|
|
|
|
// ─── 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
|
|
|
|
// Verify subscriptions happened exactly once (not twice)
|
|
_mockAppStateService.VerifyAdd(
|
|
s => s.MotionStateChanged += It.IsAny<EventHandler<StateChangedEventArgs<MotionState>>>(),
|
|
Times.Once,
|
|
"MotionStateChanged should be subscribed exactly once 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 propagate to the StateDisplay child ViewModel
|
|
/// Validates: Requirements 1.5, 2.9
|
|
/// </summary>
|
|
[Fact]
|
|
public void StateChangeEvent_PropagatesTo_StateDisplayViewModel()
|
|
{
|
|
var vm = CreateViewModel();
|
|
vm.Initialize();
|
|
|
|
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));
|
|
|
|
DoEvents();
|
|
|
|
var raySourceNode = vm.StateDisplay.StateTree.FirstOrDefault(n => n.Name == "RaySourceState");
|
|
Assert.NotNull(raySourceNode);
|
|
|
|
var isOnNode = raySourceNode.Children.FirstOrDefault(n => n.Name == "IsOn");
|
|
Assert.NotNull(isOnNode);
|
|
Assert.Equal("True", isOnNode.Value);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 状态变更事件应传播到 EventLog 子 ViewModel
|
|
/// State-change events should propagate to the EventLog child ViewModel
|
|
/// Validates: Requirements 1.5, 4.2
|
|
/// </summary>
|
|
[Fact]
|
|
public void StateChangeEvent_PropagatesTo_EventLogViewModel()
|
|
{
|
|
var vm = CreateViewModel();
|
|
vm.Initialize();
|
|
|
|
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));
|
|
|
|
DoEvents();
|
|
|
|
Assert.NotEmpty(vm.EventLog.EventLog);
|
|
Assert.Contains(vm.EventLog.EventLog, e => e.Category == "RaySourceState");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 状态变更事件应传播到 PerformanceMonitor 子 ViewModel
|
|
/// State-change events should propagate to the PerformanceMonitor child ViewModel
|
|
/// Validates: Requirements 1.5, 9.1
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task StateChangeEvent_PropagatesTo_PerformanceMonitorViewModel()
|
|
{
|
|
var vm = CreateViewModel();
|
|
vm.Initialize();
|
|
|
|
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));
|
|
|
|
// Allow dispatcher BeginInvoke to process
|
|
await Task.Delay(200);
|
|
DoEvents();
|
|
|
|
var motionMetric = vm.PerformanceMonitor.Metrics.FirstOrDefault(m => m.StateType == "MotionState");
|
|
Assert.NotNull(motionMetric);
|
|
// Latency should have been recorded (>= 0)
|
|
Assert.True(motionMetric.AverageLatency >= 0);
|
|
}
|
|
|
|
// ─── 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);
|
|
|
|
/// <summary>
|
|
/// 处理 Dispatcher 队列中的所有待处理消息
|
|
/// Process all pending messages in the Dispatcher queue
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
}
|