217 lines
10 KiB
C#
217 lines
10 KiB
C#
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
|
||
{
|
||
/// <summary>
|
||
/// FsCheck property-based tests for ViewportPanelViewModel.
|
||
/// Properties 9–10 validate IsAnimatedSwitchEnabled logic and ImageInfo protection.
|
||
/// </summary>
|
||
public class ViewportPanelViewModelPropertyTests
|
||
{
|
||
// ── Helpers ──────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// Runs an action on a dedicated STA thread with a SynchronizationContext,
|
||
/// which satisfies Prism's UIThread requirement for EventAggregator.
|
||
/// </summary>
|
||
private static T RunOnStaThread<T>(Func<T> 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<bool>()
|
||
from isCncRunning in ArbMap.Default.GeneratorFor<bool>()
|
||
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<IMainViewportService>();
|
||
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<IAppStateService>();
|
||
mockAppState.SetupAdd(s => s.DetectorStateChanged += It.IsAny<EventHandler<StateChangedEventArgs<DetectorState>>>());
|
||
|
||
var mockLogger = new Mock<ILoggerService>();
|
||
mockLogger.Setup(l => l.ForModule<ViewportPanelViewModel>()).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<DetectorState>(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<IMainViewportService>();
|
||
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<IAppStateService>();
|
||
mockAppState.SetupAdd(s => s.DetectorStateChanged += It.IsAny<EventHandler<StateChangedEventArgs<DetectorState>>>());
|
||
|
||
var mockLogger = new Mock<ILoggerService>();
|
||
mockLogger.Setup(l => l.ForModule<ViewportPanelViewModel>()).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;
|
||
});
|
||
});
|
||
}
|
||
}
|
||
}
|