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

477 lines
20 KiB
C#

using Moq;
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Threading;
using Xunit;
using XP.Common.Logging.Interfaces;
using XplorePlane.Models;
using XplorePlane.Services.AppState;
using XplorePlane.ViewModels.Debug;
namespace XplorePlane.Tests.ViewModels
{
/// <summary>
/// PerformanceMonitorViewModel 单元测试
/// Unit tests for PerformanceMonitorViewModel
///
/// Validates: Requirements 9.1-9.5
/// </summary>
public class PerformanceMonitorViewModelTests
{
private readonly Mock<IAppStateService> _mockAppStateService;
private readonly Mock<ILoggerService> _mockLoggerService;
private readonly Mock<ILoggerService> _mockModuleLogger;
private readonly Dispatcher _dispatcher;
public PerformanceMonitorViewModelTests()
{
_mockAppStateService = new Mock<IAppStateService>();
_mockLoggerService = new Mock<ILoggerService>();
_mockModuleLogger = new Mock<ILoggerService>();
_mockLoggerService
.Setup(l => l.ForModule<PerformanceMonitorViewModel>())
.Returns(_mockModuleLogger.Object);
_dispatcher = Dispatcher.CurrentDispatcher;
}
// ─── Construction ────────────────────────────────────────────────────────
/// <summary>
/// 构造函数应为每种状态类型(CalibrationMatrix 除外)初始化一个 PerformanceMetric
/// Constructor should initialise one PerformanceMetric per state category (excluding CalibrationMatrix)
/// Validates: Requirement 9.1
/// </summary>
[Fact]
public void Constructor_InitializesMetrics_ForEachStateCategory_ExceptCalibrationMatrix()
{
var vm = CreateViewModel();
Assert.Equal(7, vm.Metrics.Count);
Assert.Contains(vm.Metrics, m => m.StateType == "MotionState");
Assert.Contains(vm.Metrics, m => m.StateType == "RaySourceState");
Assert.Contains(vm.Metrics, m => m.StateType == "DetectorState");
Assert.Contains(vm.Metrics, m => m.StateType == "SystemState");
Assert.Contains(vm.Metrics, m => m.StateType == "CameraState");
Assert.Contains(vm.Metrics, m => m.StateType == "LinkedViewState");
Assert.Contains(vm.Metrics, m => m.StateType == "RecipeExecutionState");
Assert.DoesNotContain(vm.Metrics, m => m.StateType == "CalibrationMatrix");
}
/// <summary>
/// 构造函数应将所有指标的初始值设为零
/// Constructor should set all metric values to zero initially
/// Validates: Requirement 9.1
/// </summary>
[Fact]
public void Constructor_InitializesAllMetrics_WithZeroValues()
{
var vm = CreateViewModel();
Assert.All(vm.Metrics, m =>
{
Assert.Equal(0, m.EventsPerSecond);
Assert.Equal(0, m.AverageLatency);
});
}
/// <summary>
/// 构造函数应将 MaxLatency 和 IsLatencyWarning 初始化为零/false
/// Constructor should initialise MaxLatency and IsLatencyWarning to zero/false
/// Validates: Requirement 9.5
/// </summary>
[Fact]
public void Constructor_InitializesMaxLatency_AndIsLatencyWarning_ToDefaults()
{
var vm = CreateViewModel();
Assert.Equal(0, vm.MaxLatency);
Assert.False(vm.IsLatencyWarning);
}
/// <summary>
/// 构造函数应拒绝 null 参数
/// Constructor should reject null arguments
/// </summary>
[Fact]
public void Constructor_WithNullAppStateService_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() =>
new PerformanceMonitorViewModel(null!, _mockLoggerService.Object, _dispatcher));
}
[Fact]
public void Constructor_WithNullLoggerService_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() =>
new PerformanceMonitorViewModel(_mockAppStateService.Object, null!, _dispatcher));
}
[Fact]
public void Constructor_WithNullDispatcher_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() =>
new PerformanceMonitorViewModel(_mockAppStateService.Object, _mockLoggerService.Object, null!));
}
// ─── Initialize / Dispose ────────────────────────────────────────────────
/// <summary>
/// Initialize 应订阅 7 个状态变更事件
/// Initialize should subscribe to 7 state-change events
/// Validates: Requirement 9.1
/// </summary>
[Fact]
public void Initialize_SubscribesToAllStateChangeEvents()
{
var vm = CreateViewModel();
vm.Initialize();
_mockAppStateService.VerifyAdd(s => s.MotionStateChanged += It.IsAny<EventHandler<StateChangedEventArgs<MotionState>>>(), Times.Once);
_mockAppStateService.VerifyAdd(s => s.RaySourceStateChanged += It.IsAny<EventHandler<StateChangedEventArgs<RaySourceState>>>(), Times.Once);
_mockAppStateService.VerifyAdd(s => s.DetectorStateChanged += It.IsAny<EventHandler<StateChangedEventArgs<DetectorState>>>(), Times.Once);
_mockAppStateService.VerifyAdd(s => s.SystemStateChanged += It.IsAny<EventHandler<StateChangedEventArgs<SystemState>>>(), Times.Once);
_mockAppStateService.VerifyAdd(s => s.CameraStateChanged += It.IsAny<EventHandler<StateChangedEventArgs<CameraState>>>(), Times.Once);
_mockAppStateService.VerifyAdd(s => s.LinkedViewStateChanged += It.IsAny<EventHandler<StateChangedEventArgs<LinkedViewState>>>(), Times.Once);
_mockAppStateService.VerifyAdd(s => s.RecipeExecutionStateChanged += It.IsAny<EventHandler<StateChangedEventArgs<RecipeExecutionState>>>(), Times.Once);
}
/// <summary>
/// 重复调用 Initialize 不应重复订阅事件
/// Calling Initialize twice should not double-subscribe events
/// </summary>
[Fact]
public void Initialize_CalledTwice_DoesNotDoubleSubscribe()
{
var vm = CreateViewModel();
vm.Initialize();
vm.Initialize();
_mockAppStateService.VerifyAdd(s => s.MotionStateChanged += It.IsAny<EventHandler<StateChangedEventArgs<MotionState>>>(), Times.Once);
}
/// <summary>
/// Dispose 应停止计时器并清空集合
/// Dispose should stop the timer and clear collections
/// Validates: Requirement 9.1
/// </summary>
[Fact]
public void Dispose_ClearsMetricsAndTrendData()
{
var vm = CreateViewModel();
vm.Initialize();
vm.Dispose();
Assert.Empty(vm.Metrics);
Assert.Empty(vm.TrendData);
}
/// <summary>
/// 重复调用 Dispose 不应抛出异常
/// Calling Dispose twice should not throw
/// </summary>
[Fact]
public void Dispose_CalledTwice_DoesNotThrow()
{
var vm = CreateViewModel();
vm.Initialize();
var ex = Record.Exception(() =>
{
vm.Dispose();
vm.Dispose();
});
Assert.Null(ex);
}
// ─── Event frequency statistics ──────────────────────────────────────────
/// <summary>
/// 触发状态变更事件后,事件应被记录到内部队列
/// After raising a state-change event, it should be recorded in the internal queue
/// Validates: Requirement 9.1
/// </summary>
[Fact]
public void StateChangeEvent_RecordsEventInQueue()
{
var vm = CreateViewModel();
vm.Initialize();
RaiseMotionStateEvent();
// Verify the event was enqueued (check via reflection)
var queueField = typeof(PerformanceMonitorViewModel)
.GetField("_eventQueue", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
var queue = queueField?.GetValue(vm) as System.Collections.Concurrent.ConcurrentQueue<(string Category, DateTime Timestamp)>;
Assert.NotNull(queue);
Assert.False(queue.IsEmpty, "Event queue should contain at least one entry after raising a state-change event");
}
/// <summary>
/// 多次触发同一类型事件后,队列中应有对应数量的记录
/// Multiple events of the same type should all be recorded in the queue
/// Validates: Requirement 9.1
/// </summary>
[Fact]
public void MultipleStateChangeEvents_AreAllRecordedInQueue()
{
var vm = CreateViewModel();
vm.Initialize();
for (int i = 0; i < 5; i++)
{
RaiseMotionStateEvent();
}
var queueField = typeof(PerformanceMonitorViewModel)
.GetField("_eventQueue", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
var queue = queueField?.GetValue(vm) as System.Collections.Concurrent.ConcurrentQueue<(string Category, DateTime Timestamp)>;
Assert.NotNull(queue);
Assert.True(queue.Count >= 5, $"Queue should have at least 5 entries, but has {queue.Count}");
}
// ─── Latency monitoring ───────────────────────────────────────────────────
/// <summary>
/// 触发状态变更事件后,延迟历史应被更新(通过反射验证)
/// After a state-change event, latency history should be updated (verified via reflection)
/// Validates: Requirement 9.4
/// </summary>
[Fact]
public void StateChangeEvent_RecordsTimestampForLatencyCalculation()
{
var vm = CreateViewModel();
vm.Initialize();
var before = DateTime.UtcNow;
RaiseMotionStateEvent();
var after = DateTime.UtcNow;
// Verify the event was enqueued with a timestamp in the expected range
var queueField = typeof(PerformanceMonitorViewModel)
.GetField("_eventQueue", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
var queue = queueField?.GetValue(vm) as System.Collections.Concurrent.ConcurrentQueue<(string Category, DateTime Timestamp)>;
Assert.NotNull(queue);
Assert.False(queue.IsEmpty);
queue.TryPeek(out var entry);
Assert.Equal("MotionState", entry.Category);
Assert.True(entry.Timestamp >= before && entry.Timestamp <= after.AddMilliseconds(50),
"Event timestamp should be within the expected range");
}
/// <summary>
/// MaxLatency 应反映所有指标中的最大延迟(通过直接注入延迟值验证)
/// MaxLatency should reflect the highest latency across all metrics (verified by direct injection)
/// Validates: Requirement 9.4
/// </summary>
[Fact]
public void MaxLatency_ReflectsHighestMetricLatency_AfterDirectInjection()
{
var vm = CreateViewModel();
vm.Initialize();
// Inject known latency values directly via the private UpdateLatencyMetric method
var method = typeof(PerformanceMonitorViewModel)
.GetMethod("UpdateLatencyMetric", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
method?.Invoke(vm, new object[] { "MotionState", 100.0 });
method?.Invoke(vm, new object[] { "RaySourceState", 200.0 });
Assert.Equal(200.0, vm.MaxLatency, precision: 1);
Assert.False(vm.IsLatencyWarning, "200ms is below the 500ms threshold");
}
/// <summary>
/// 当 MaxLatency 超过 500ms 时,IsLatencyWarning 应为 true
/// IsLatencyWarning should be true when MaxLatency exceeds 500ms
/// Validates: Requirement 9.5
/// </summary>
[Fact]
public void IsLatencyWarning_IsFalse_WhenMaxLatencyBelowThreshold()
{
var vm = CreateViewModel();
// MaxLatency starts at 0, well below 500ms
Assert.False(vm.IsLatencyWarning);
}
/// <summary>
/// 通过直接注入高延迟值,验证 IsLatencyWarning 变为 true
/// Inject a high latency value directly to verify IsLatencyWarning becomes true
/// Validates: Requirement 9.5
/// </summary>
[Fact]
public void IsLatencyWarning_IsTrue_WhenMaxLatencyExceeds500ms()
{
var vm = CreateViewModel();
vm.Initialize();
var method = typeof(PerformanceMonitorViewModel)
.GetMethod("UpdateLatencyMetric", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
// Inject a latency value above the 500ms threshold
method?.Invoke(vm, new object[] { "MotionState", 600.0 });
Assert.True(vm.MaxLatency > 500, $"MaxLatency should be > 500ms, was {vm.MaxLatency}");
Assert.True(vm.IsLatencyWarning, "IsLatencyWarning should be true when MaxLatency > 500ms");
}
// ─── Trend data management ────────────────────────────────────────────────
/// <summary>
/// 计时器触发后,TrendData 应增加一个数据点(通过直接调用私有方法验证)
/// TrendData should gain one data point when UpdateTrendData is called directly
/// Validates: Requirement 9.2
/// </summary>
[Fact]
public void UpdateTrendData_AddsTrendDataPoint()
{
var vm = CreateViewModel();
vm.Initialize();
var initialCount = vm.TrendData.Count;
var method = typeof(PerformanceMonitorViewModel)
.GetMethod("UpdateTrendData", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
method?.Invoke(vm, null);
Assert.Equal(initialCount + 1, vm.TrendData.Count);
}
/// <summary>
/// TrendData 应限制为最多 60 个数据点(60 秒)
/// TrendData should be capped at 60 data points (60 seconds)
/// Validates: Requirement 9.2
/// </summary>
[Fact]
public void TrendData_IsLimitedTo60DataPoints()
{
var vm = CreateViewModel();
vm.Initialize();
// Directly invoke the private UpdateTrendData method 70 times via reflection
var method = typeof(PerformanceMonitorViewModel)
.GetMethod("UpdateTrendData", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
for (int i = 0; i < 70; i++)
{
method?.Invoke(vm, null);
}
Assert.True(vm.TrendData.Count <= 60,
$"TrendData should not exceed 60 points, but has {vm.TrendData.Count}");
}
/// <summary>
/// TrendData 数据点应包含时间戳和总事件频率
/// TrendData points should contain a timestamp and total event frequency value
/// Validates: Requirement 9.2
/// </summary>
[Fact]
public void TrendData_DataPoints_HaveTimestampAndValue()
{
var vm = CreateViewModel();
vm.Initialize();
var method = typeof(PerformanceMonitorViewModel)
.GetMethod("UpdateTrendData", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
method?.Invoke(vm, null);
Assert.NotEmpty(vm.TrendData);
var point = vm.TrendData.Last();
Assert.True(point.Timestamp > DateTime.MinValue, "TrendDataPoint.Timestamp should be set");
Assert.True(point.Value >= 0, "TrendDataPoint.Value should be non-negative");
}
/// <summary>
/// TrendData 数据点的 Value 应等于所有指标 EventsPerSecond 的总和
/// TrendData Value should equal the sum of all metric EventsPerSecond values
/// Validates: Requirement 9.2
/// </summary>
[Fact]
public void TrendData_Value_EqualsSumOfAllMetricFrequencies()
{
var vm = CreateViewModel();
vm.Initialize();
// Set known EventsPerSecond values on metrics
foreach (var metric in vm.Metrics)
{
metric.EventsPerSecond = 2.0;
}
var method = typeof(PerformanceMonitorViewModel)
.GetMethod("UpdateTrendData", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
method?.Invoke(vm, null);
var expectedTotal = vm.Metrics.Sum(m => m.EventsPerSecond);
var lastPoint = vm.TrendData.Last();
Assert.Equal(expectedTotal, lastPoint.Value);
}
// ─── Thread safety ────────────────────────────────────────────────────────
/// <summary>
/// 从后台线程触发状态变更事件不应抛出异常
/// Raising state-change events from a background thread should not throw
/// Validates: Requirement 9.1 (thread safety)
/// </summary>
[Fact]
public async Task StateChangeEvent_FromBackgroundThread_DoesNotThrow()
{
var vm = CreateViewModel();
vm.Initialize();
Exception? caughtException = null;
await Task.Run(() =>
{
try
{
for (int i = 0; i < 10; i++)
{
RaiseMotionStateEvent();
Thread.Sleep(10);
}
}
catch (Exception ex)
{
caughtException = ex;
}
});
await Task.Delay(200);
Assert.Null(caughtException);
}
// ─── Helpers ─────────────────────────────────────────────────────────────
private PerformanceMonitorViewModel CreateViewModel() =>
new PerformanceMonitorViewModel(
_mockAppStateService.Object,
_mockLoggerService.Object,
_dispatcher);
private void RaiseMotionStateEvent()
{
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));
}
}
}