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 { /// /// PerformanceMonitorViewModel 单元测试 /// Unit tests for PerformanceMonitorViewModel /// /// Validates: Requirements 9.1-9.5 /// public class PerformanceMonitorViewModelTests { private readonly Mock _mockAppStateService; private readonly Mock _mockLoggerService; private readonly Mock _mockModuleLogger; private readonly Dispatcher _dispatcher; public PerformanceMonitorViewModelTests() { _mockAppStateService = new Mock(); _mockLoggerService = new Mock(); _mockModuleLogger = new Mock(); _mockLoggerService .Setup(l => l.ForModule()) .Returns(_mockModuleLogger.Object); _dispatcher = Dispatcher.CurrentDispatcher; } // ─── Construction ──────────────────────────────────────────────────────── /// /// 构造函数应为每种状态类型(CalibrationMatrix 除外)初始化一个 PerformanceMetric /// Constructor should initialise one PerformanceMetric per state category (excluding CalibrationMatrix) /// Validates: Requirement 9.1 /// [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"); } /// /// 构造函数应将所有指标的初始值设为零 /// Constructor should set all metric values to zero initially /// Validates: Requirement 9.1 /// [Fact] public void Constructor_InitializesAllMetrics_WithZeroValues() { var vm = CreateViewModel(); Assert.All(vm.Metrics, m => { Assert.Equal(0, m.EventsPerSecond); Assert.Equal(0, m.AverageLatency); }); } /// /// 构造函数应将 MaxLatency 和 IsLatencyWarning 初始化为零/false /// Constructor should initialise MaxLatency and IsLatencyWarning to zero/false /// Validates: Requirement 9.5 /// [Fact] public void Constructor_InitializesMaxLatency_AndIsLatencyWarning_ToDefaults() { var vm = CreateViewModel(); Assert.Equal(0, vm.MaxLatency); Assert.False(vm.IsLatencyWarning); } /// /// 构造函数应拒绝 null 参数 /// Constructor should reject null arguments /// [Fact] public void Constructor_WithNullAppStateService_ThrowsArgumentNullException() { Assert.Throws(() => new PerformanceMonitorViewModel(null!, _mockLoggerService.Object, _dispatcher)); } [Fact] public void Constructor_WithNullLoggerService_ThrowsArgumentNullException() { Assert.Throws(() => new PerformanceMonitorViewModel(_mockAppStateService.Object, null!, _dispatcher)); } [Fact] public void Constructor_WithNullDispatcher_ThrowsArgumentNullException() { Assert.Throws(() => new PerformanceMonitorViewModel(_mockAppStateService.Object, _mockLoggerService.Object, null!)); } // ─── Initialize / Dispose ──────────────────────────────────────────────── /// /// Initialize 应订阅 7 个状态变更事件 /// Initialize should subscribe to 7 state-change events /// Validates: Requirement 9.1 /// [Fact] public void Initialize_SubscribesToAllStateChangeEvents() { var vm = CreateViewModel(); vm.Initialize(); _mockAppStateService.VerifyAdd(s => s.MotionStateChanged += It.IsAny>>(), Times.Once); _mockAppStateService.VerifyAdd(s => s.RaySourceStateChanged += It.IsAny>>(), Times.Once); _mockAppStateService.VerifyAdd(s => s.DetectorStateChanged += It.IsAny>>(), Times.Once); _mockAppStateService.VerifyAdd(s => s.SystemStateChanged += It.IsAny>>(), Times.Once); _mockAppStateService.VerifyAdd(s => s.CameraStateChanged += It.IsAny>>(), Times.Once); _mockAppStateService.VerifyAdd(s => s.LinkedViewStateChanged += It.IsAny>>(), Times.Once); _mockAppStateService.VerifyAdd(s => s.RecipeExecutionStateChanged += It.IsAny>>(), Times.Once); } /// /// 重复调用 Initialize 不应重复订阅事件 /// Calling Initialize twice should not double-subscribe events /// [Fact] public void Initialize_CalledTwice_DoesNotDoubleSubscribe() { var vm = CreateViewModel(); vm.Initialize(); vm.Initialize(); _mockAppStateService.VerifyAdd(s => s.MotionStateChanged += It.IsAny>>(), Times.Once); } /// /// Dispose 应停止计时器并清空集合 /// Dispose should stop the timer and clear collections /// Validates: Requirement 9.1 /// [Fact] public void Dispose_ClearsMetricsAndTrendData() { var vm = CreateViewModel(); vm.Initialize(); vm.Dispose(); Assert.Empty(vm.Metrics); Assert.Empty(vm.TrendData); } /// /// 重复调用 Dispose 不应抛出异常 /// Calling Dispose twice should not throw /// [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 ────────────────────────────────────────── /// /// 触发状态变更事件后,事件应被记录到内部队列 /// After raising a state-change event, it should be recorded in the internal queue /// Validates: Requirement 9.1 /// [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"); } /// /// 多次触发同一类型事件后,队列中应有对应数量的记录 /// Multiple events of the same type should all be recorded in the queue /// Validates: Requirement 9.1 /// [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 ─────────────────────────────────────────────────── /// /// 触发状态变更事件后,延迟历史应被更新(通过反射验证) /// After a state-change event, latency history should be updated (verified via reflection) /// Validates: Requirement 9.4 /// [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"); } /// /// MaxLatency 应反映所有指标中的最大延迟(通过直接注入延迟值验证) /// MaxLatency should reflect the highest latency across all metrics (verified by direct injection) /// Validates: Requirement 9.4 /// [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"); } /// /// 当 MaxLatency 超过 500ms 时,IsLatencyWarning 应为 true /// IsLatencyWarning should be true when MaxLatency exceeds 500ms /// Validates: Requirement 9.5 /// [Fact] public void IsLatencyWarning_IsFalse_WhenMaxLatencyBelowThreshold() { var vm = CreateViewModel(); // MaxLatency starts at 0, well below 500ms Assert.False(vm.IsLatencyWarning); } /// /// 通过直接注入高延迟值,验证 IsLatencyWarning 变为 true /// Inject a high latency value directly to verify IsLatencyWarning becomes true /// Validates: Requirement 9.5 /// [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 ──────────────────────────────────────────────── /// /// 计时器触发后,TrendData 应增加一个数据点(通过直接调用私有方法验证) /// TrendData should gain one data point when UpdateTrendData is called directly /// Validates: Requirement 9.2 /// [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); } /// /// TrendData 应限制为最多 60 个数据点(60 秒) /// TrendData should be capped at 60 data points (60 seconds) /// Validates: Requirement 9.2 /// [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}"); } /// /// TrendData 数据点应包含时间戳和总事件频率 /// TrendData points should contain a timestamp and total event frequency value /// Validates: Requirement 9.2 /// [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"); } /// /// TrendData 数据点的 Value 应等于所有指标 EventsPerSecond 的总和 /// TrendData Value should equal the sum of all metric EventsPerSecond values /// Validates: Requirement 9.2 /// [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 ──────────────────────────────────────────────────────── /// /// 从后台线程触发状态变更事件不应抛出异常 /// Raising state-change events from a background thread should not throw /// Validates: Requirement 9.1 (thread safety) /// [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(oldState, newState)); } } }