双队列的打通 实时按钮的切换 补充测试用例
This commit is contained in:
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// FsCheck property-based tests for DetectorFramePipelineService configuration correction.
|
||||
/// Property 11 validates that values <= 0 are corrected to 1.
|
||||
/// </summary>
|
||||
public class ConfigCorrectionPropertyTests
|
||||
{
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private static DetectorFramePipelineService CreateServiceWithConfig(
|
||||
int acquireCapacity,
|
||||
int processCapacity,
|
||||
int processEveryN)
|
||||
{
|
||||
var mockLogger = new Mock<ILoggerService>();
|
||||
mockLogger.Setup(l => l.ForModule<DetectorFramePipelineService>()).Returns(mockLogger.Object);
|
||||
|
||||
var mockMainViewport = new Mock<IMainViewportService>();
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// FsCheck property-based tests for DetectorFramePipelineService.
|
||||
/// Properties 6–8 validate queue bounds and sampling correctness.
|
||||
/// </summary>
|
||||
public class DetectorFramePipelineServicePropertyTests
|
||||
{
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static (DetectorFramePipelineService Service, EventAggregator EventAggregator)
|
||||
CreateService(int acquireCapacity = 16, int processCapacity = 8, int processEveryN = 1)
|
||||
{
|
||||
var mockLogger = new Mock<ILoggerService>();
|
||||
mockLogger.Setup(l => l.ForModule<DetectorFramePipelineService>()).Returns(mockLogger.Object);
|
||||
|
||||
var mockMainViewport = new Mock<IMainViewportService>();
|
||||
var eventAggregator = new EventAggregator();
|
||||
|
||||
var service = new DetectorFramePipelineService(
|
||||
eventAggregator,
|
||||
mockMainViewport.Object,
|
||||
mockLogger.Object,
|
||||
acquireCapacity,
|
||||
processCapacity,
|
||||
processEveryN);
|
||||
|
||||
return (service, eventAggregator);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes M frames via the EventAggregator and waits for background processing.
|
||||
/// </summary>
|
||||
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<ImageCapturedEvent>().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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// FsCheck property-based tests for MainViewportService.
|
||||
/// Uses the real MainViewportService (no mocking of the service itself).
|
||||
/// </summary>
|
||||
public class MainViewportServicePropertyTests
|
||||
{
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private static MainViewportService CreateService()
|
||||
{
|
||||
var mockLogger = new Mock<ILoggerService>();
|
||||
mockLogger.Setup(l => l.ForModule<MainViewportService>()).Returns(mockLogger.Object);
|
||||
return new MainViewportService(mockLogger.Object);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a frozen BitmapSource suitable for cross-thread use in tests.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generator for DetectorFrame with a frozen BitmapSource.
|
||||
/// </summary>
|
||||
private static Gen<DetectorFrame> 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<DetectorFrame>(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<DetectorFrame>)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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <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;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user