using System; using System.Threading; using System.Windows.Media; using System.Windows.Media.Imaging; using System.Windows.Threading; using FsCheck; using FsCheck.Fluent; using FsCheck.Xunit; using Moq; using Prism.Events; using XP.Common.Logging.Interfaces; using XplorePlane.Events; using XplorePlane.Models; using XplorePlane.Services.AppState; using XplorePlane.Services.MainViewport; using XplorePlane.ViewModels; namespace XplorePlane.Tests.ViewModels { /// /// FsCheck property-based tests for ViewportPanelViewModel. /// Properties 9–10 validate IsAnimatedSwitchEnabled logic and ImageInfo protection. /// public class ViewportPanelViewModelPropertyTests { // ── Helpers ────────────────────────────────────────────────────────── /// /// Runs an action on a dedicated STA thread with a SynchronizationContext, /// which satisfies Prism's UIThread requirement for EventAggregator. /// private static T RunOnStaThread(Func action) { T result = default; Exception exception = null; var thread = new Thread(() => { SynchronizationContext.SetSynchronizationContext(new DispatcherSynchronizationContext()); try { result = action(); } catch (Exception ex) { exception = ex; } }); thread.SetApartmentState(ApartmentState.STA); thread.Start(); thread.Join(); if (exception != null) throw new Exception("STA thread threw an exception", exception); return result; } // ── Property 9 ─────────────────────────────────────────────────────── // Feature: live-image-display, Property 9: AnimatedSwitch 可用状态与连接/CNC 状态一致 // Validates: Requirements 3.13, 7.5, 7.6 // // For any combination of IsDetectorConnected and IsCncRunning, // IsAnimatedSwitchEnabled always equals IsDetectorConnected && !IsCncRunning. // // The implementation in ViewportPanelViewModel computes: // IsAnimatedSwitchEnabled = _isDetectorConnected && !_isCncRunning // We verify this formula holds for all boolean combinations. [Property(MaxTest = 100)] public Property IsAnimatedSwitchEnabled_AlwaysEqualsDetectorConnectedAndNotCncRunning() { var gen = from isDetectorConnected in ArbMap.Default.GeneratorFor() from isCncRunning in ArbMap.Default.GeneratorFor() select (isDetectorConnected, isCncRunning); return Prop.ForAll( gen.ToArbitrary(), tuple => { var (isDetectorConnected, isCncRunning) = tuple; // Run the entire test on a single STA thread with SynchronizationContext return RunOnStaThread(() => { var mockViewport = new Mock(); mockViewport.Setup(s => s.CurrentDisplayImage).Returns((ImageSource)null); mockViewport.Setup(s => s.CurrentDisplayInfo).Returns("等待探测器图像..."); mockViewport.Setup(s => s.IsRealtimeDisplayEnabled).Returns(true); mockViewport.Setup(s => s.IsCncRunning).Returns(isCncRunning); var mockAppState = new Mock(); mockAppState.SetupAdd(s => s.DetectorStateChanged += It.IsAny>>()); var mockLogger = new Mock(); mockLogger.Setup(l => l.ForModule()).Returns(mockLogger.Object); var eventAggregator = new EventAggregator(); var vm = new ViewportPanelViewModel( mockViewport.Object, eventAggregator, mockAppState.Object, mockLogger.Object); // Simulate detector connection state change via DetectorStateChanged event. var detectorState = new DetectorState(isDetectorConnected, false, 0, ""); var oldState = new DetectorState(!isDetectorConnected, false, 0, ""); var stateChangedArgs = new StateChangedEventArgs(oldState, detectorState); mockAppState.Raise(s => s.DetectorStateChanged += null, stateChangedArgs); // Pump the dispatcher to process BeginInvoke callbacks Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.Background); // Simulate CNC running state via StateChanged on the viewport service mockViewport.Setup(s => s.IsCncRunning).Returns(isCncRunning); mockViewport.Raise(s => s.StateChanged += null, EventArgs.Empty); // Pump the dispatcher again Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.Background); // The core invariant: IsAnimatedSwitchEnabled == IsDetectorConnected && !IsCncRunning return vm.IsAnimatedSwitchEnabled == (vm.IsDetectorConnected && !isCncRunning); }); }); } // ── Property 10 ────────────────────────────────────────────────────── // Feature: live-image-display, Property 10: 测量模式下 ImageInfo 不被覆盖 // Validates: Requirements 5.3 // // For any active measurement mode (CurrentMeasurementMode != None) and any number // of StateChanged triggers, ImageInfo always retains the measurement hint text // and is not overwritten by service state. // // Implementation note: ViewportPanelViewModel.OnMainViewportStateChanged calls // UpdateFromState(updateInfo: CurrentMeasurementMode == MeasurementToolMode.None) // When CurrentMeasurementMode != None, updateInfo is false, so ImageInfo is NOT updated. // We verify this by directly setting CurrentMeasurementMode and triggering StateChanged. [Property(MaxTest = 100)] public Property ActiveMeasurementMode_ImageInfo_NotOverwrittenByStateChanged() { // Generate non-None measurement modes var modeGen = Gen.Elements( MeasurementToolMode.PointDistance, MeasurementToolMode.PointLineDistance, MeasurementToolMode.Angle, MeasurementToolMode.ThroughHoleFillRate); var gen = from mode in modeGen from triggerCount in Gen.Choose(1, 20) select (mode, triggerCount); return Prop.ForAll( gen.ToArbitrary(), tuple => { var (mode, triggerCount) = tuple; // Run the entire test on a single STA thread with SynchronizationContext return RunOnStaThread(() => { var mockViewport = new Mock(); mockViewport.Setup(s => s.CurrentDisplayImage).Returns((ImageSource)null); mockViewport.Setup(s => s.CurrentDisplayInfo).Returns("等待探测器图像..."); mockViewport.Setup(s => s.IsRealtimeDisplayEnabled).Returns(true); mockViewport.Setup(s => s.IsCncRunning).Returns(false); var mockAppState = new Mock(); mockAppState.SetupAdd(s => s.DetectorStateChanged += It.IsAny>>()); var mockLogger = new Mock(); mockLogger.Setup(l => l.ForModule()).Returns(mockLogger.Object); var eventAggregator = new EventAggregator(); var vm = new ViewportPanelViewModel( mockViewport.Object, eventAggregator, mockAppState.Object, mockLogger.Object); // Directly set the measurement mode on the ViewModel. // This bypasses the UIThread event dispatch issue and directly // sets the backing field that controls ImageInfo protection. vm.CurrentMeasurementMode = mode; // Set ImageInfo to the measurement hint text (as OnMeasurementToolActivated would) vm.ImageInfo = vm.MeasurementModeText; string measurementInfo = vm.ImageInfo; // Verify measurement mode is active if (vm.CurrentMeasurementMode == MeasurementToolMode.None) return false; // Trigger StateChanged multiple times. // The handler calls BeginInvoke — we pump the dispatcher to process callbacks. for (int i = 0; i < triggerCount; i++) { mockViewport.Raise(s => s.StateChanged += null, EventArgs.Empty); // Pump the dispatcher to process BeginInvoke callbacks Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.Background); } // ImageInfo must still be the measurement hint text, not the service info. // This holds because UpdateFromState is called with updateInfo: false // when CurrentMeasurementMode != None. return vm.ImageInfo == measurementInfo; }); }); } } }