477 lines
20 KiB
C#
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));
|
|
}
|
|
}
|
|
}
|