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 + { + } +}