diff --git a/XplorePlane.Tests/Services/ConfigCorrectionPropertyTests.cs b/XplorePlane.Tests/Services/ConfigCorrectionPropertyTests.cs
new file mode 100644
index 0000000..c3b9d59
--- /dev/null
+++ b/XplorePlane.Tests/Services/ConfigCorrectionPropertyTests.cs
@@ -0,0 +1,101 @@
+using FsCheck;
+using FsCheck.Fluent;
+using FsCheck.Xunit;
+using Moq;
+using Prism.Events;
+using XP.Common.Logging.Interfaces;
+using XplorePlane.Services.MainViewport;
+
+namespace XplorePlane.Tests.Services
+{
+ ///
+ /// FsCheck property-based tests for DetectorFramePipelineService configuration correction.
+ /// Property 11 validates that values <= 0 are corrected to 1.
+ ///
+ public class ConfigCorrectionPropertyTests
+ {
+ // ── Helpers ──────────────────────────────────────────────────────────
+
+ private static DetectorFramePipelineService CreateServiceWithConfig(
+ int acquireCapacity,
+ int processCapacity,
+ int processEveryN)
+ {
+ var mockLogger = new Mock();
+ mockLogger.Setup(l => l.ForModule()).Returns(mockLogger.Object);
+
+ var mockMainViewport = new Mock();
+ var eventAggregator = new EventAggregator();
+
+ return new DetectorFramePipelineService(
+ eventAggregator,
+ mockMainViewport.Object,
+ mockLogger.Object,
+ acquireCapacity,
+ processCapacity,
+ processEveryN);
+ }
+
+ // ── Property 11 ──────────────────────────────────────────────────────
+
+ // Feature: live-image-display, Property 11: 配置值下界修正
+ // Validates: Requirements 6.3, 6.4
+ //
+ // For any integer config value <= 0 (ProcessEveryNFrames, AcquireQueueCapacity,
+ // ProcessQueueCapacity), DetectorFramePipelineService corrects it to 1.
+ [Property(MaxTest = 100)]
+ public Property InvalidConfigValues_AreCorrectedToOne()
+ {
+ // Generate values <= 0 (including 0 and negative integers)
+ var nonPositiveGen = Gen.Choose(int.MinValue / 2, 0);
+
+ var gen =
+ from acquireCapacity in nonPositiveGen
+ from processCapacity in nonPositiveGen
+ from processEveryN in nonPositiveGen
+ select (acquireCapacity, processCapacity, processEveryN);
+
+ return Prop.ForAll(
+ gen.ToArbitrary(),
+ tuple =>
+ {
+ var (acquireCapacity, processCapacity, processEveryN) = tuple;
+
+ using var service = CreateServiceWithConfig(acquireCapacity, processCapacity, processEveryN);
+
+ // All three values must be corrected to 1
+ bool acquireCorrected = service.AcquireQueueCapacity == 1;
+ bool processCorrected = service.ProcessQueueCapacity == 1;
+ bool everyNCorrected = service.ProcessEveryNFrames == 1;
+
+ return acquireCorrected && processCorrected && everyNCorrected;
+ });
+ }
+
+ // Additional property: valid positive values are preserved as-is
+ [Property(MaxTest = 100)]
+ public Property ValidConfigValues_ArePreservedAsIs()
+ {
+ var positiveGen = Gen.Choose(1, 1000);
+
+ var gen =
+ from acquireCapacity in positiveGen
+ from processCapacity in positiveGen
+ from processEveryN in positiveGen
+ select (acquireCapacity, processCapacity, processEveryN);
+
+ return Prop.ForAll(
+ gen.ToArbitrary(),
+ tuple =>
+ {
+ var (acquireCapacity, processCapacity, processEveryN) = tuple;
+
+ using var service = CreateServiceWithConfig(acquireCapacity, processCapacity, processEveryN);
+
+ return service.AcquireQueueCapacity == acquireCapacity
+ && service.ProcessQueueCapacity == processCapacity
+ && service.ProcessEveryNFrames == processEveryN;
+ });
+ }
+ }
+}
diff --git a/XplorePlane.Tests/Services/DetectorFramePipelineServicePropertyTests.cs b/XplorePlane.Tests/Services/DetectorFramePipelineServicePropertyTests.cs
new file mode 100644
index 0000000..62e1d70
--- /dev/null
+++ b/XplorePlane.Tests/Services/DetectorFramePipelineServicePropertyTests.cs
@@ -0,0 +1,194 @@
+using System;
+using System.Threading;
+using FsCheck;
+using FsCheck.Fluent;
+using FsCheck.Xunit;
+using Moq;
+using Prism.Events;
+using XP.Common.Logging.Interfaces;
+using XP.Hardware.Detector.Abstractions;
+using XP.Hardware.Detector.Abstractions.Events;
+using XplorePlane.Services.MainViewport;
+
+namespace XplorePlane.Tests.Services
+{
+ ///
+ /// FsCheck property-based tests for DetectorFramePipelineService.
+ /// Properties 6–8 validate queue bounds and sampling correctness.
+ ///
+ public class DetectorFramePipelineServicePropertyTests
+ {
+ // ── Helpers ──────────────────────────────────────────────────────────
+
+ ///
+ /// Creates a DetectorFramePipelineService with the given capacity/sampling config.
+ /// Uses the internal test constructor to bypass App.config reads.
+ /// Uses a real EventAggregator so we can publish ImageCapturedEvent to drive the service.
+ ///
+ private static (DetectorFramePipelineService Service, EventAggregator EventAggregator)
+ CreateService(int acquireCapacity = 16, int processCapacity = 8, int processEveryN = 1)
+ {
+ var mockLogger = new Mock();
+ mockLogger.Setup(l => l.ForModule()).Returns(mockLogger.Object);
+
+ var mockMainViewport = new Mock();
+ var eventAggregator = new EventAggregator();
+
+ var service = new DetectorFramePipelineService(
+ eventAggregator,
+ mockMainViewport.Object,
+ mockLogger.Object,
+ acquireCapacity,
+ processCapacity,
+ processEveryN);
+
+ return (service, eventAggregator);
+ }
+
+ ///
+ /// Publishes M frames via the EventAggregator and waits for background processing.
+ ///
+ private static void PublishFrames(EventAggregator eventAggregator, int frameCount, int width = 4, int height = 4)
+ {
+ for (int i = 0; i < frameCount; i++)
+ {
+ var args = new ImageCapturedEventArgs
+ {
+ ImageData = new ushort[width * height],
+ Width = (uint)width,
+ Height = (uint)height,
+ FrameNumber = i + 1,
+ CaptureTime = DateTime.UtcNow
+ };
+ eventAggregator.GetEvent().Publish(args);
+ }
+
+ // Wait for background thread processing to complete.
+ // The subscription runs on BackgroundThread, so we need a brief wait.
+ Thread.Sleep(300);
+ }
+
+ // ── Property 6 ───────────────────────────────────────────────────────
+
+ // Feature: live-image-display, Property 6: 采集队列有界不变量
+ // Validates: Requirements 2.2
+ [Property(MaxTest = 100)]
+ public Property AcquireQueueCount_NeverExceedsCapacity()
+ {
+ var gen =
+ from capacity in Gen.Choose(1, 10)
+ from frameCount in Gen.Choose(1, 100)
+ select (capacity, frameCount);
+
+ return Prop.ForAll(
+ gen.ToArbitrary(),
+ tuple =>
+ {
+ var (capacity, frameCount) = tuple;
+ var (service, eventAggregator) = CreateService(acquireCapacity: capacity);
+
+ try
+ {
+ PublishFrames(eventAggregator, frameCount);
+ return service.AcquireQueueCount <= service.AcquireQueueCapacity;
+ }
+ finally
+ {
+ service.Dispose();
+ }
+ });
+ }
+
+ // ── Property 7 ───────────────────────────────────────────────────────
+
+ // Feature: live-image-display, Property 7: 处理队列有界不变量
+ // Validates: Requirements 2.4
+ [Property(MaxTest = 100)]
+ public Property ProcessQueueCount_NeverExceedsCapacity()
+ {
+ var gen =
+ from capacity in Gen.Choose(1, 10)
+ from frameCount in Gen.Choose(1, 100)
+ select (capacity, frameCount);
+
+ return Prop.ForAll(
+ gen.ToArbitrary(),
+ tuple =>
+ {
+ var (capacity, frameCount) = tuple;
+ var (service, eventAggregator) = CreateService(processCapacity: capacity);
+
+ try
+ {
+ PublishFrames(eventAggregator, frameCount);
+ return service.ProcessQueueCount <= service.ProcessQueueCapacity;
+ }
+ finally
+ {
+ service.Dispose();
+ }
+ });
+ }
+
+ // ── Property 8 ───────────────────────────────────────────────────────
+
+ // Feature: live-image-display, Property 8: 隔帧抽样正确性
+ // Validates: Requirements 2.3
+ //
+ // For any N (ProcessEveryNFrames) and M frames, the number of frames entering
+ // the process queue equals ceil(M / N).
+ //
+ // We use a large process queue capacity to avoid overflow dropping frames,
+ // and count frames via ProcessFrameDequeued events.
+ [Property(MaxTest = 100)]
+ public Property ProcessQueueEntries_EqualsCeilMDivN()
+ {
+ var gen =
+ from n in Gen.Choose(1, 10)
+ from m in Gen.Choose(1, 50)
+ select (n, m);
+
+ return Prop.ForAll(
+ gen.ToArbitrary(),
+ tuple =>
+ {
+ var (n, m) = tuple;
+
+ // Use a large process queue capacity so no frames are dropped due to overflow
+ var (service, eventAggregator) = CreateService(
+ acquireCapacity: 200,
+ processCapacity: 200,
+ processEveryN: n);
+
+ int dequeuedCount = 0;
+ using var allDequeued = new SemaphoreSlim(0);
+ int expected = (int)Math.Ceiling((double)m / n);
+
+ service.ProcessFrameDequeued += (_, __) =>
+ {
+ int count = Interlocked.Increment(ref dequeuedCount);
+ if (count >= expected)
+ allDequeued.Release();
+ };
+
+ try
+ {
+ PublishFrames(eventAggregator, m);
+
+ // Wait up to 5 seconds for all expected frames to be dequeued
+ bool completed = allDequeued.Wait(TimeSpan.FromSeconds(5));
+
+ // If we didn't get the expected count, give a bit more time
+ if (!completed)
+ Thread.Sleep(500);
+
+ return dequeuedCount == expected;
+ }
+ finally
+ {
+ service.Dispose();
+ }
+ });
+ }
+ }
+}
diff --git a/XplorePlane.Tests/Services/MainViewportServicePropertyTests.cs b/XplorePlane.Tests/Services/MainViewportServicePropertyTests.cs
new file mode 100644
index 0000000..8bde9c0
--- /dev/null
+++ b/XplorePlane.Tests/Services/MainViewportServicePropertyTests.cs
@@ -0,0 +1,208 @@
+using System;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using FsCheck;
+using FsCheck.Fluent;
+using FsCheck.Xunit;
+using Moq;
+using XP.Common.Logging.Interfaces;
+using XplorePlane.Services.MainViewport;
+
+namespace XplorePlane.Tests.Services
+{
+ ///
+ /// FsCheck property-based tests for MainViewportService.
+ /// Uses the real MainViewportService (no mocking of the service itself).
+ ///
+ public class MainViewportServicePropertyTests
+ {
+ // ── Helpers ──────────────────────────────────────────────────────────
+
+ private static MainViewportService CreateService()
+ {
+ var mockLogger = new Mock();
+ mockLogger.Setup(l => l.ForModule()).Returns(mockLogger.Object);
+ return new MainViewportService(mockLogger.Object);
+ }
+
+ ///
+ /// Creates a frozen BitmapSource suitable for cross-thread use in tests.
+ ///
+ private static BitmapSource CreateFrozenBitmap(int width = 4, int height = 4)
+ {
+ var pixels = new byte[width * height * 4];
+ var bitmap = BitmapSource.Create(
+ width, height, 96, 96,
+ PixelFormats.Bgra32, null,
+ pixels, width * 4);
+ bitmap.Freeze();
+ return bitmap;
+ }
+
+ ///
+ /// Generator for DetectorFrame with a frozen BitmapSource.
+ ///
+ private static Gen DetectorFrameGen =>
+ from frameId in Gen.Choose(1, 100000).Select(i => (long)i)
+ from width in Gen.Choose(1, 64)
+ from height in Gen.Choose(1, 64)
+ select new DetectorFrame(
+ frameId: frameId,
+ captureTime: DateTime.UtcNow,
+ width: width,
+ height: height,
+ rawPixels: new ushort[width * height],
+ previewImage: CreateFrozenBitmap(width, height));
+
+ // ── Property 1 ───────────────────────────────────────────────────────
+
+ // Feature: live-image-display, Property 1: 实时模式下帧更新触发显示刷新
+ // Validates: Requirements 3.2
+ [Property(MaxTest = 100)]
+ public Property LiveDetector_RealtimeEnabled_UpdateDetectorFrame_UpdatesDisplayAndRaisesEvent()
+ {
+ return Prop.ForAll(
+ DetectorFrameGen.ToArbitrary(),
+ frame =>
+ {
+ var service = CreateService();
+
+ // Ensure we are in LiveDetector mode with realtime enabled (default state)
+ service.SetSourceMode(MainViewportSourceMode.LiveDetector);
+ service.SetRealtimeDisplayEnabled(true);
+
+ bool stateChangedRaised = false;
+ service.StateChanged += (_, __) => stateChangedRaised = true;
+
+ service.UpdateDetectorFrame(frame);
+
+ bool imageMatches = ReferenceEquals(service.CurrentDisplayImage, frame.PreviewImage);
+ bool eventRaised = stateChangedRaised;
+
+ return imageMatches && eventRaised;
+ });
+ }
+
+ // ── Property 2 ───────────────────────────────────────────────────────
+
+ // Feature: live-image-display, Property 2: 实时关闭时帧更新不触发显示刷新
+ // Validates: Requirements 3.3
+ [Property(MaxTest = 100)]
+ public Property LiveDetector_RealtimeDisabled_UpdateDetectorFrame_NoEventAndImageUnchanged()
+ {
+ return Prop.ForAll(
+ DetectorFrameGen.ToArbitrary(),
+ frame =>
+ {
+ var service = CreateService();
+
+ // Set up: LiveDetector mode, realtime disabled
+ service.SetSourceMode(MainViewportSourceMode.LiveDetector);
+ service.SetRealtimeDisplayEnabled(false);
+
+ // Capture the display image before the update
+ var imageBefore = service.CurrentDisplayImage;
+
+ bool stateChangedRaised = false;
+ service.StateChanged += (_, __) => stateChangedRaised = true;
+
+ service.UpdateDetectorFrame(frame);
+
+ bool noEvent = !stateChangedRaised;
+ bool imageUnchanged = ReferenceEquals(service.CurrentDisplayImage, imageBefore);
+
+ return noEvent && imageUnchanged;
+ });
+ }
+
+ // ── Property 3 ───────────────────────────────────────────────────────
+
+ // Feature: live-image-display, Property 3: ManualImage 模式下探测器帧不覆盖显示图像
+ // Validates: Requirements 3.4
+ [Property(MaxTest = 100)]
+ public Property ManualImage_MultipleUpdateDetectorFrame_DisplayAlwaysManualImage()
+ {
+ var gen =
+ from frameCount in Gen.Choose(1, 10)
+ from frames in Gen.ListOf(DetectorFrameGen, frameCount)
+ select frames;
+
+ return Prop.ForAll(
+ gen.ToArbitrary(),
+ frames =>
+ {
+ var service = CreateService();
+
+ // Set a manual image first
+ var manualImage = CreateFrozenBitmap(8, 8);
+ service.SetManualImage(manualImage, "test.png");
+
+ // Verify we are in ManualImage mode
+ if (service.CurrentSourceMode != MainViewportSourceMode.ManualImage)
+ return false;
+
+ // Call UpdateDetectorFrame multiple times
+ foreach (var frame in (System.Collections.Generic.IEnumerable)frames)
+ {
+ service.UpdateDetectorFrame(frame);
+ }
+
+ // CurrentDisplayImage must still be the manual image
+ return ReferenceEquals(service.CurrentDisplayImage, manualImage);
+ });
+ }
+
+ // ── Property 4 ───────────────────────────────────────────────────────
+
+ // Feature: live-image-display, Property 4: CNC 运行时 SetManualImage 被忽略
+ // Validates: Requirements 3.10
+ [Property(MaxTest = 100)]
+ public Property CncRunning_SetManualImage_SourceModeRemainsLiveDetector()
+ {
+ return Prop.ForAll(
+ DetectorFrameGen.ToArbitrary(),
+ frame =>
+ {
+ var service = CreateService();
+
+ // Start CNC running
+ service.SetCncRunning(true);
+
+ // Attempt to set a manual image
+ var manualImage = CreateFrozenBitmap(4, 4);
+ service.SetManualImage(manualImage, "manual.png");
+
+ // CurrentSourceMode must remain LiveDetector
+ return service.CurrentSourceMode == MainViewportSourceMode.LiveDetector;
+ });
+ }
+
+ // ── Property 5 ───────────────────────────────────────────────────────
+
+ // Feature: live-image-display, Property 5: CNC 运行时无法关闭实时刷新
+ // Validates: Requirements 3.11
+ [Property(MaxTest = 100)]
+ public Property CncRunning_SetRealtimeDisplayEnabledFalse_IsRealtimeDisplayEnabledRemainsTrue()
+ {
+ // No generator needed — fixed input
+ return Prop.ForAll(
+ Gen.Constant(true).ToArbitrary(),
+ _ =>
+ {
+ var service = CreateService();
+
+ // Ensure realtime is enabled first
+ service.SetRealtimeDisplayEnabled(true);
+
+ // Start CNC running
+ service.SetCncRunning(true);
+
+ // Attempt to disable realtime display
+ service.SetRealtimeDisplayEnabled(false);
+
+ // IsRealtimeDisplayEnabled must remain true
+ return service.IsRealtimeDisplayEnabled;
+ });
+ }
+ }
+}
diff --git a/XplorePlane.Tests/ViewModels/ViewportPanelViewModelPropertyTests.cs b/XplorePlane.Tests/ViewModels/ViewportPanelViewModelPropertyTests.cs
new file mode 100644
index 0000000..29a7c85
--- /dev/null
+++ b/XplorePlane.Tests/ViewModels/ViewportPanelViewModelPropertyTests.cs
@@ -0,0 +1,216 @@
+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;
+ });
+ });
+ }
+ }
+}
diff --git a/XplorePlane/Events/DetectorDisconnectedEvent.cs b/XplorePlane/Events/DetectorDisconnectedEvent.cs
new file mode 100644
index 0000000..5bec93d
--- /dev/null
+++ b/XplorePlane/Events/DetectorDisconnectedEvent.cs
@@ -0,0 +1,12 @@
+using Prism.Events;
+
+namespace XplorePlane.Events
+{
+ ///
+ /// 探测器断连事件。
+ /// 当探测器连接状态从已连接变为断开时,由 AppStateService 发布。
+ ///
+ public sealed class DetectorDisconnectedEvent : PubSubEvent
+ {
+ }
+}