Compare commits
2 Commits
f9be56b99f
...
d56caf1ab5
| Author | SHA1 | Date | |
|---|---|---|---|
| d56caf1ab5 | |||
| bd9b24beb1 |
Binary file not shown.
File diff suppressed because it is too large
Load Diff
-11
@@ -1,11 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<configuration>
|
|
||||||
<runtime>
|
|
||||||
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
|
|
||||||
<dependentAssembly>
|
|
||||||
<assemblyIdentity name="Newtonsoft.Json" publicKeyToken="30ad4fe6b2a6aeed" culture="neutral" />
|
|
||||||
<bindingRedirect oldVersion="0.0.0.0-13.0.0.0" newVersion="13.0.0.0" />
|
|
||||||
</dependentAssembly>
|
|
||||||
</assemblyBinding>
|
|
||||||
</runtime>
|
|
||||||
</configuration>
|
|
||||||
@@ -9,8 +9,10 @@ using FsCheck;
|
|||||||
using FsCheck.Fluent;
|
using FsCheck.Fluent;
|
||||||
using FsCheck.Xunit;
|
using FsCheck.Xunit;
|
||||||
using Moq;
|
using Moq;
|
||||||
|
using Prism.Events;
|
||||||
using XP.Common.Logging.Interfaces;
|
using XP.Common.Logging.Interfaces;
|
||||||
using XP.Hardware.Detector.Abstractions;
|
using XP.Hardware.Detector.Abstractions;
|
||||||
|
using XplorePlane.Events;
|
||||||
using XplorePlane.Models;
|
using XplorePlane.Models;
|
||||||
using XplorePlane.Services.Cnc;
|
using XplorePlane.Services.Cnc;
|
||||||
using XplorePlane.Services;
|
using XplorePlane.Services;
|
||||||
@@ -185,8 +187,14 @@ internal sealed class SynchronousProgress<T> : IProgress<T>
|
|||||||
var mockAppStateService = new Mock<IAppStateService>();
|
var mockAppStateService = new Mock<IAppStateService>();
|
||||||
var mockPipelineExecutionService = new Mock<IPipelineExecutionService>();
|
var mockPipelineExecutionService = new Mock<IPipelineExecutionService>();
|
||||||
var mockImageProcessingService = new Mock<IImageProcessingService>();
|
var mockImageProcessingService = new Mock<IImageProcessingService>();
|
||||||
|
var mockEventAggregator = new Mock<IEventAggregator>();
|
||||||
mockLogger.Setup(l => l.ForModule<CncExecutionService>()).Returns(mockLogger.Object);
|
mockLogger.Setup(l => l.ForModule<CncExecutionService>()).Returns(mockLogger.Object);
|
||||||
|
|
||||||
|
// Set up GetEvent<DetectorDisconnectedEvent>() so the constructor subscription doesn't throw
|
||||||
|
mockEventAggregator
|
||||||
|
.Setup(ea => ea.GetEvent<DetectorDisconnectedEvent>())
|
||||||
|
.Returns(new DetectorDisconnectedEvent());
|
||||||
|
|
||||||
mockStore.Setup(s => s.BeginRunAsync(
|
mockStore.Setup(s => s.BeginRunAsync(
|
||||||
It.IsAny<InspectionRunRecord>(),
|
It.IsAny<InspectionRunRecord>(),
|
||||||
It.IsAny<InspectionAssetWriteRequest>()))
|
It.IsAny<InspectionAssetWriteRequest>()))
|
||||||
@@ -211,7 +219,8 @@ internal sealed class SynchronousProgress<T> : IProgress<T>
|
|||||||
mockMainViewportService.Object,
|
mockMainViewportService.Object,
|
||||||
mockAppStateService.Object,
|
mockAppStateService.Object,
|
||||||
mockPipelineExecutionService.Object,
|
mockPipelineExecutionService.Object,
|
||||||
mockImageProcessingService.Object);
|
mockImageProcessingService.Object,
|
||||||
|
mockEventAggregator.Object);
|
||||||
return (service, mockStore, mockLogger, mockMainViewportService, mockAppStateService);
|
return (service, mockStore, mockLogger, mockMainViewportService, mockAppStateService);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using Prism.Events;
|
||||||
|
|
||||||
|
namespace XplorePlane.Events
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 探测器断连事件。
|
||||||
|
/// 当探测器连接状态从已连接变为断开时,由 AppStateService 发布。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DetectorDisconnectedEvent : PubSubEvent
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ using XP.Hardware.MotionControl.Abstractions.Enums;
|
|||||||
using XP.Hardware.MotionControl.Abstractions.Events;
|
using XP.Hardware.MotionControl.Abstractions.Events;
|
||||||
using XP.Hardware.MotionControl.Services;
|
using XP.Hardware.MotionControl.Services;
|
||||||
using XP.Hardware.RaySource.Services;
|
using XP.Hardware.RaySource.Services;
|
||||||
|
using XplorePlane.Events;
|
||||||
using XplorePlane.Models;
|
using XplorePlane.Models;
|
||||||
|
|
||||||
namespace XplorePlane.Services.AppState
|
namespace XplorePlane.Services.AppState
|
||||||
@@ -367,6 +368,9 @@ namespace XplorePlane.Services.AppState
|
|||||||
{
|
{
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
|
|
||||||
|
// 在更新状态前记录当前连接状态,用于检测断连转换
|
||||||
|
bool wasConnected = _detectorState?.IsConnected ?? false;
|
||||||
|
|
||||||
// 从 IDetectorService 读取分辨率等补充信息
|
// 从 IDetectorService 读取分辨率等补充信息
|
||||||
string resolution = string.Empty;
|
string resolution = string.Empty;
|
||||||
double frameRate = 0;
|
double frameRate = 0;
|
||||||
@@ -396,6 +400,13 @@ namespace XplorePlane.Services.AppState
|
|||||||
"探测器状态已同步:{Status} → IsConnected={IsConnected} IsAcquiring={IsAcquiring} | " +
|
"探测器状态已同步:{Status} → IsConnected={IsConnected} IsAcquiring={IsAcquiring} | " +
|
||||||
"Detector state synced: {Status} → IsConnected={IsConnected} IsAcquiring={IsAcquiring}",
|
"Detector state synced: {Status} → IsConnected={IsConnected} IsAcquiring={IsAcquiring}",
|
||||||
status, isConnected, isAcquiring);
|
status, isConnected, isAcquiring);
|
||||||
|
|
||||||
|
// 检测从已连接变为断开,发布断连事件
|
||||||
|
if (wasConnected && !isConnected)
|
||||||
|
{
|
||||||
|
_eventAggregator.GetEvent<DetectorDisconnectedEvent>().Publish();
|
||||||
|
_logger.Warn("探测器已断连,发布 DetectorDisconnectedEvent | Detector disconnected, publishing DetectorDisconnectedEvent");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Media.Imaging;
|
using System.Windows.Media.Imaging;
|
||||||
|
using Prism.Events;
|
||||||
using XP.Common.Converters;
|
using XP.Common.Converters;
|
||||||
using XP.Common.Logging.Interfaces;
|
using XP.Common.Logging.Interfaces;
|
||||||
|
using XplorePlane.Events;
|
||||||
using XplorePlane.Models;
|
using XplorePlane.Models;
|
||||||
using XplorePlane.Services.AppState;
|
using XplorePlane.Services.AppState;
|
||||||
using XplorePlane.Services.InspectionResults;
|
using XplorePlane.Services.InspectionResults;
|
||||||
@@ -27,6 +29,10 @@ namespace XplorePlane.Services.Cnc
|
|||||||
private readonly IAppStateService _appStateService;
|
private readonly IAppStateService _appStateService;
|
||||||
private readonly IPipelineExecutionService _pipelineExecutionService;
|
private readonly IPipelineExecutionService _pipelineExecutionService;
|
||||||
private readonly IImageProcessingService _imageProcessingService;
|
private readonly IImageProcessingService _imageProcessingService;
|
||||||
|
private readonly IEventAggregator _eventAggregator;
|
||||||
|
|
||||||
|
// Task 4.2: volatile field so reads/writes are not reordered across threads
|
||||||
|
private volatile CancellationTokenSource _executionCts;
|
||||||
|
|
||||||
public CncExecutionService(
|
public CncExecutionService(
|
||||||
IInspectionResultStore store,
|
IInspectionResultStore store,
|
||||||
@@ -34,7 +40,8 @@ namespace XplorePlane.Services.Cnc
|
|||||||
IMainViewportService mainViewportService,
|
IMainViewportService mainViewportService,
|
||||||
IAppStateService appStateService,
|
IAppStateService appStateService,
|
||||||
IPipelineExecutionService pipelineExecutionService,
|
IPipelineExecutionService pipelineExecutionService,
|
||||||
IImageProcessingService imageProcessingService)
|
IImageProcessingService imageProcessingService,
|
||||||
|
IEventAggregator eventAggregator)
|
||||||
{
|
{
|
||||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
@@ -42,12 +49,36 @@ namespace XplorePlane.Services.Cnc
|
|||||||
_appStateService = appStateService ?? throw new ArgumentNullException(nameof(appStateService));
|
_appStateService = appStateService ?? throw new ArgumentNullException(nameof(appStateService));
|
||||||
_pipelineExecutionService = pipelineExecutionService;
|
_pipelineExecutionService = pipelineExecutionService;
|
||||||
_imageProcessingService = imageProcessingService;
|
_imageProcessingService = imageProcessingService;
|
||||||
|
_eventAggregator = eventAggregator ?? throw new ArgumentNullException(nameof(eventAggregator));
|
||||||
|
|
||||||
|
// Task 4.3: subscribe to DetectorDisconnectedEvent on a background thread
|
||||||
|
_eventAggregator.GetEvent<DetectorDisconnectedEvent>()
|
||||||
|
.Subscribe(OnDetectorDisconnected, ThreadOption.BackgroundThread);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task 4.3: callback – cancel the running execution when the detector disconnects
|
||||||
|
private void OnDetectorDisconnected()
|
||||||
|
{
|
||||||
|
var cts = _executionCts;
|
||||||
|
if (cts == null) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
cts.Cancel();
|
||||||
|
_logger.ForModule<CncExecutionService>().Warn("探测器断连,已取消当前 CNC 执行");
|
||||||
|
}
|
||||||
|
catch (ObjectDisposedException) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ExecuteAsync(CncProgram program, IProgress<CncNodeExecutionProgress> progress, CancellationToken cancellationToken)
|
public async Task ExecuteAsync(CncProgram program, IProgress<CncNodeExecutionProgress> progress, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Task 4.4: create a linked CTS so DetectorDisconnectedEvent can cancel independently
|
||||||
|
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
_executionCts = linkedCts;
|
||||||
|
_mainViewportService?.SetCncRunning(true);
|
||||||
|
try
|
||||||
{
|
{
|
||||||
// Pre-cancellation check - do NOT call BeginRunAsync if already cancelled
|
// Pre-cancellation check - do NOT call BeginRunAsync if already cancelled
|
||||||
if (cancellationToken.IsCancellationRequested)
|
if (linkedCts.Token.IsCancellationRequested)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
int inspectionNodeCount = program.Nodes.OfType<InspectionModuleNode>().Count();
|
int inspectionNodeCount = program.Nodes.OfType<InspectionModuleNode>().Count();
|
||||||
@@ -91,7 +122,7 @@ namespace XplorePlane.Services.Cnc
|
|||||||
|
|
||||||
foreach (var node in program.Nodes.OrderBy(n => n.Index))
|
foreach (var node in program.Nodes.OrderBy(n => n.Index))
|
||||||
{
|
{
|
||||||
if (cancellationToken.IsCancellationRequested)
|
if (linkedCts.Token.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
break;
|
break;
|
||||||
@@ -165,7 +196,7 @@ namespace XplorePlane.Services.Cnc
|
|||||||
case WaitDelayNode waitNode:
|
case WaitDelayNode waitNode:
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await ExecuteWaitDelayWithProgressAsync(waitNode, progress, cancellationToken);
|
await ExecuteWaitDelayWithProgressAsync(waitNode, progress, linkedCts.Token);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -176,14 +207,14 @@ namespace XplorePlane.Services.Cnc
|
|||||||
case PauseDialogNode pauseNode:
|
case PauseDialogNode pauseNode:
|
||||||
await Application.Current.Dispatcher.InvokeAsync(() =>
|
await Application.Current.Dispatcher.InvokeAsync(() =>
|
||||||
MessageBox.Show(pauseNode.DialogMessage, pauseNode.DialogTitle));
|
MessageBox.Show(pauseNode.DialogMessage, pauseNode.DialogTitle));
|
||||||
if (cancellationToken.IsCancellationRequested)
|
if (linkedCts.Token.IsCancellationRequested)
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case InspectionModuleNode inspectionNode:
|
case InspectionModuleNode inspectionNode:
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var img = await ExecuteInspectionNodeAsync(runId, inspectionNode, sourceImage, cancellationToken);
|
var img = await ExecuteInspectionNodeAsync(runId, inspectionNode, sourceImage, linkedCts.Token);
|
||||||
if (img != null) lastResultImage = img;
|
if (img != null) lastResultImage = img;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -208,7 +239,7 @@ namespace XplorePlane.Services.Cnc
|
|||||||
{
|
{
|
||||||
_logger.ForModule<CncExecutionService>().Error(ex,
|
_logger.ForModule<CncExecutionService>().Error(ex,
|
||||||
"Unexpected error executing node '{0}' (Id={1})", node.Name, node.Id);
|
"Unexpected error executing node '{0}' (Id={1})", node.Name, node.Id);
|
||||||
if (cancellationToken.IsCancellationRequested)
|
if (linkedCts.Token.IsCancellationRequested)
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
else
|
else
|
||||||
nodeSucceeded = false;
|
nodeSucceeded = false;
|
||||||
@@ -242,6 +273,12 @@ namespace XplorePlane.Services.Cnc
|
|||||||
_logger.ForModule<CncExecutionService>().Error(ex,
|
_logger.ForModule<CncExecutionService>().Error(ex,
|
||||||
"Failed to complete inspection run '{0}'", runId);
|
"Failed to complete inspection run '{0}'", runId);
|
||||||
}
|
}
|
||||||
|
} // end try
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_executionCts = null;
|
||||||
|
_mainViewportService?.SetCncRunning(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private BitmapSource TryGetSourceImage()
|
private BitmapSource TryGetSourceImage()
|
||||||
@@ -255,7 +292,7 @@ namespace XplorePlane.Services.Cnc
|
|||||||
if (detectorFrame?.ImageData == null || detectorFrame.Width <= 0 || detectorFrame.Height <= 0)
|
if (detectorFrame?.ImageData == null || detectorFrame.Width <= 0 || detectorFrame.Height <= 0)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var bitmap = ImageConverter.ConvertGray16ToBitmapSource(
|
var bitmap = XP.Common.Converters.ImageConverter.ConvertGray16ToBitmapSource(
|
||||||
detectorFrame.ImageData,
|
detectorFrame.ImageData,
|
||||||
(int)detectorFrame.Width,
|
(int)detectorFrame.Width,
|
||||||
(int)detectorFrame.Height);
|
(int)detectorFrame.Height);
|
||||||
|
|||||||
@@ -29,14 +29,32 @@ namespace XplorePlane.Services.MainViewport
|
|||||||
IEventAggregator eventAggregator,
|
IEventAggregator eventAggregator,
|
||||||
IMainViewportService mainViewportService,
|
IMainViewportService mainViewportService,
|
||||||
ILoggerService logger)
|
ILoggerService logger)
|
||||||
|
: this(eventAggregator, mainViewportService, logger,
|
||||||
|
ReadInt("DetectorPipeline:AcquireQueueCapacity", 16, 1),
|
||||||
|
ReadInt("DetectorPipeline:ProcessQueueCapacity", 8, 1),
|
||||||
|
ReadInt("DetectorPipeline:ProcessEveryNFrames", 1, 1))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Internal constructor for testing: accepts capacity and sampling values directly,
|
||||||
|
/// bypassing App.config reads.
|
||||||
|
/// </summary>
|
||||||
|
internal DetectorFramePipelineService(
|
||||||
|
IEventAggregator eventAggregator,
|
||||||
|
IMainViewportService mainViewportService,
|
||||||
|
ILoggerService logger,
|
||||||
|
int acquireQueueCapacity,
|
||||||
|
int processQueueCapacity,
|
||||||
|
int processEveryNFrames)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(eventAggregator);
|
ArgumentNullException.ThrowIfNull(eventAggregator);
|
||||||
_mainViewportService = mainViewportService ?? throw new ArgumentNullException(nameof(mainViewportService));
|
_mainViewportService = mainViewportService ?? throw new ArgumentNullException(nameof(mainViewportService));
|
||||||
_logger = logger?.ForModule<DetectorFramePipelineService>() ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger?.ForModule<DetectorFramePipelineService>() ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
|
||||||
AcquireQueueCapacity = ReadInt("DetectorPipeline:AcquireQueueCapacity", 16, 1);
|
AcquireQueueCapacity = Math.Max(1, acquireQueueCapacity);
|
||||||
ProcessQueueCapacity = ReadInt("DetectorPipeline:ProcessQueueCapacity", 8, 1);
|
ProcessQueueCapacity = Math.Max(1, processQueueCapacity);
|
||||||
ProcessEveryNFrames = ReadInt("DetectorPipeline:ProcessEveryNFrames", 1, 1);
|
ProcessEveryNFrames = Math.Max(1, processEveryNFrames);
|
||||||
|
|
||||||
eventAggregator.GetEvent<ImageCapturedEvent>()
|
eventAggregator.GetEvent<ImageCapturedEvent>()
|
||||||
.Subscribe(OnImageCaptured, ThreadOption.BackgroundThread);
|
.Subscribe(OnImageCaptured, ThreadOption.BackgroundThread);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace XplorePlane.Services.MainViewport
|
|||||||
{
|
{
|
||||||
MainViewportSourceMode CurrentSourceMode { get; }
|
MainViewportSourceMode CurrentSourceMode { get; }
|
||||||
bool IsRealtimeDisplayEnabled { get; }
|
bool IsRealtimeDisplayEnabled { get; }
|
||||||
|
bool IsCncRunning { get; }
|
||||||
ImageSource CurrentDisplayImage { get; }
|
ImageSource CurrentDisplayImage { get; }
|
||||||
string CurrentDisplayInfo { get; }
|
string CurrentDisplayInfo { get; }
|
||||||
ImageSource LatestDetectorImage { get; }
|
ImageSource LatestDetectorImage { get; }
|
||||||
@@ -21,5 +22,11 @@ namespace XplorePlane.Services.MainViewport
|
|||||||
void UpdateDetectorFrame(DetectorFrame frame);
|
void UpdateDetectorFrame(DetectorFrame frame);
|
||||||
|
|
||||||
void SetManualImage(ImageSource image, string filePath);
|
void SetManualImage(ImageSource image, string filePath);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 通知 MainViewportService 当前 CNC 运行状态。
|
||||||
|
/// CNC 开始运行时传入 true,结束时传入 false。
|
||||||
|
/// </summary>
|
||||||
|
void SetCncRunning(bool isRunning);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ namespace XplorePlane.Services.MainViewport
|
|||||||
|
|
||||||
private MainViewportSourceMode _currentSourceMode = MainViewportSourceMode.LiveDetector;
|
private MainViewportSourceMode _currentSourceMode = MainViewportSourceMode.LiveDetector;
|
||||||
private bool _isRealtimeDisplayEnabled;
|
private bool _isRealtimeDisplayEnabled;
|
||||||
|
private bool _isCncRunning;
|
||||||
private ImageSource _currentDisplayImage;
|
private ImageSource _currentDisplayImage;
|
||||||
private string _currentDisplayInfo = "等待探测器图像...";
|
private string _currentDisplayInfo = "等待探测器图像...";
|
||||||
private ImageSource _latestDetectorImage;
|
private ImageSource _latestDetectorImage;
|
||||||
@@ -48,6 +49,17 @@ namespace XplorePlane.Services.MainViewport
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool IsCncRunning
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
return _isCncRunning;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public ImageSource CurrentDisplayImage
|
public ImageSource CurrentDisplayImage
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
@@ -99,11 +111,21 @@ namespace XplorePlane.Services.MainViewport
|
|||||||
bool changed;
|
bool changed;
|
||||||
lock (_syncRoot)
|
lock (_syncRoot)
|
||||||
{
|
{
|
||||||
changed = _isRealtimeDisplayEnabled != isEnabled;
|
if (!isEnabled && _isCncRunning)
|
||||||
|
{
|
||||||
|
_logger.Warn("CNC 正在运行,忽略 SetRealtimeDisplayEnabled(false) 调用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
changed = _isRealtimeDisplayEnabled != isEnabled
|
||||||
|
|| (isEnabled && _currentSourceMode == MainViewportSourceMode.ManualImage);
|
||||||
|
|
||||||
_isRealtimeDisplayEnabled = isEnabled;
|
_isRealtimeDisplayEnabled = isEnabled;
|
||||||
|
|
||||||
if (_currentSourceMode == MainViewportSourceMode.LiveDetector && isEnabled)
|
if (isEnabled)
|
||||||
{
|
{
|
||||||
|
// 开启实时:无论当前是 ManualImage 还是 LiveDetector,都切回实时帧显示
|
||||||
|
_currentSourceMode = MainViewportSourceMode.LiveDetector;
|
||||||
ApplyLiveDetectorDisplay_NoLock();
|
ApplyLiveDetectorDisplay_NoLock();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,6 +195,12 @@ namespace XplorePlane.Services.MainViewport
|
|||||||
|
|
||||||
lock (_syncRoot)
|
lock (_syncRoot)
|
||||||
{
|
{
|
||||||
|
if (_isCncRunning)
|
||||||
|
{
|
||||||
|
_logger.Warn("CNC 正在运行,忽略 SetManualImage 调用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_latestManualImage = image;
|
_latestManualImage = image;
|
||||||
_latestManualInfo = $"手动加载图像 {fileName}";
|
_latestManualInfo = $"手动加载图像 {fileName}";
|
||||||
_currentSourceMode = MainViewportSourceMode.ManualImage;
|
_currentSourceMode = MainViewportSourceMode.ManualImage;
|
||||||
@@ -184,6 +212,23 @@ namespace XplorePlane.Services.MainViewport
|
|||||||
RaiseStateChanged();
|
RaiseStateChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void SetCncRunning(bool isRunning)
|
||||||
|
{
|
||||||
|
bool modeChanged = false;
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
_isCncRunning = isRunning;
|
||||||
|
if (isRunning && _currentSourceMode == MainViewportSourceMode.ManualImage)
|
||||||
|
{
|
||||||
|
_currentSourceMode = MainViewportSourceMode.LiveDetector;
|
||||||
|
ApplyLiveDetectorDisplay_NoLock();
|
||||||
|
modeChanged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_logger.Info("CNC 运行状态已更新:{IsRunning}", isRunning);
|
||||||
|
if (modeChanged) RaiseStateChanged();
|
||||||
|
}
|
||||||
|
|
||||||
private void ApplyLiveDetectorDisplay_NoLock()
|
private void ApplyLiveDetectorDisplay_NoLock()
|
||||||
{
|
{
|
||||||
_currentDisplayImage = _latestDetectorImage;
|
_currentDisplayImage = _latestDetectorImage;
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ using System.Windows;
|
|||||||
using System.Windows.Media;
|
using System.Windows.Media;
|
||||||
using XP.Common.Logging.Interfaces;
|
using XP.Common.Logging.Interfaces;
|
||||||
using XplorePlane.Events;
|
using XplorePlane.Events;
|
||||||
|
using XplorePlane.Models;
|
||||||
|
using XplorePlane.Services.AppState;
|
||||||
using XplorePlane.Services.MainViewport;
|
using XplorePlane.Services.MainViewport;
|
||||||
|
|
||||||
namespace XplorePlane.ViewModels
|
namespace XplorePlane.ViewModels
|
||||||
@@ -18,6 +20,7 @@ namespace XplorePlane.ViewModels
|
|||||||
private readonly ILoggerService _logger;
|
private readonly ILoggerService _logger;
|
||||||
private readonly IMainViewportService _mainViewportService;
|
private readonly IMainViewportService _mainViewportService;
|
||||||
private readonly IEventAggregator _eventAggregator;
|
private readonly IEventAggregator _eventAggregator;
|
||||||
|
private readonly IAppStateService _appStateService;
|
||||||
|
|
||||||
private ImageSource _imageSource;
|
private ImageSource _imageSource;
|
||||||
private string _imageInfo = "等待探测器图像...";
|
private string _imageInfo = "等待探测器图像...";
|
||||||
@@ -26,21 +29,42 @@ namespace XplorePlane.ViewModels
|
|||||||
private Point? _measurePoint2;
|
private Point? _measurePoint2;
|
||||||
private string _measurementResult;
|
private string _measurementResult;
|
||||||
|
|
||||||
|
// Task 5.2: IsRealtimeEnabled backing field
|
||||||
|
private bool _isRealtimeEnabled;
|
||||||
|
|
||||||
|
// Task 5.3: IsDetectorConnected backing field
|
||||||
|
private bool _isDetectorConnected = true;
|
||||||
|
|
||||||
|
// Task 5.4: IsCncRunning backing field
|
||||||
|
private bool _isCncRunning;
|
||||||
|
|
||||||
public ViewportPanelViewModel(
|
public ViewportPanelViewModel(
|
||||||
IMainViewportService mainViewportService,
|
IMainViewportService mainViewportService,
|
||||||
IEventAggregator eventAggregator,
|
IEventAggregator eventAggregator,
|
||||||
|
IAppStateService appStateService,
|
||||||
ILoggerService logger)
|
ILoggerService logger)
|
||||||
{
|
{
|
||||||
_mainViewportService = mainViewportService ?? throw new ArgumentNullException(nameof(mainViewportService));
|
_mainViewportService = mainViewportService ?? throw new ArgumentNullException(nameof(mainViewportService));
|
||||||
_eventAggregator = eventAggregator ?? throw new ArgumentNullException(nameof(eventAggregator));
|
_eventAggregator = eventAggregator ?? throw new ArgumentNullException(nameof(eventAggregator));
|
||||||
|
_appStateService = appStateService ?? throw new ArgumentNullException(nameof(appStateService));
|
||||||
_logger = logger?.ForModule<ViewportPanelViewModel>() ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger?.ForModule<ViewportPanelViewModel>() ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
|
||||||
CancelMeasurementCommand = new DelegateCommand(ExecuteCancelMeasurement);
|
CancelMeasurementCommand = new DelegateCommand(ExecuteCancelMeasurement);
|
||||||
|
|
||||||
|
// Task 5.5: ToggleRealtimeCommand
|
||||||
|
ToggleRealtimeCommand = new DelegateCommand(() => IsRealtimeEnabled = !IsRealtimeEnabled);
|
||||||
|
|
||||||
_mainViewportService.StateChanged += OnMainViewportStateChanged;
|
_mainViewportService.StateChanged += OnMainViewportStateChanged;
|
||||||
_eventAggregator.GetEvent<MeasurementToolEvent>()
|
_eventAggregator.GetEvent<MeasurementToolEvent>()
|
||||||
.Subscribe(OnMeasurementToolActivated, ThreadOption.UIThread);
|
.Subscribe(OnMeasurementToolActivated, ThreadOption.UIThread);
|
||||||
|
|
||||||
|
// Task 5.6: Subscribe to DetectorStateChanged
|
||||||
|
_appStateService.DetectorStateChanged += OnDetectorStateChanged;
|
||||||
|
|
||||||
|
// Task 5.7: Subscribe to DetectorDisconnectedEvent on UI thread
|
||||||
|
_eventAggregator.GetEvent<DetectorDisconnectedEvent>()
|
||||||
|
.Subscribe(OnDetectorDisconnectedForUI, ThreadOption.UIThread);
|
||||||
|
|
||||||
UpdateFromState(updateInfo: true);
|
UpdateFromState(updateInfo: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +80,31 @@ namespace XplorePlane.ViewModels
|
|||||||
set => SetProperty(ref _imageInfo, value);
|
set => SetProperty(ref _imageInfo, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Task 5.2: IsRealtimeEnabled property (two-way binding)
|
||||||
|
public bool IsRealtimeEnabled
|
||||||
|
{
|
||||||
|
get => _isRealtimeEnabled;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _isRealtimeEnabled, value))
|
||||||
|
_mainViewportService.SetRealtimeDisplayEnabled(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task 5.3: IsDetectorConnected property (read-only, private setter)
|
||||||
|
public bool IsDetectorConnected
|
||||||
|
{
|
||||||
|
get => _isDetectorConnected;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _isDetectorConnected, value))
|
||||||
|
RaisePropertyChanged(nameof(IsAnimatedSwitchEnabled));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task 5.4: IsAnimatedSwitchEnabled computed property
|
||||||
|
public bool IsAnimatedSwitchEnabled => _isDetectorConnected && !_isCncRunning;
|
||||||
|
|
||||||
public MeasurementToolMode CurrentMeasurementMode
|
public MeasurementToolMode CurrentMeasurementMode
|
||||||
{
|
{
|
||||||
get => _currentMeasurementMode;
|
get => _currentMeasurementMode;
|
||||||
@@ -100,6 +149,9 @@ namespace XplorePlane.ViewModels
|
|||||||
|
|
||||||
public DelegateCommand CancelMeasurementCommand { get; }
|
public DelegateCommand CancelMeasurementCommand { get; }
|
||||||
|
|
||||||
|
// Task 5.5: ToggleRealtimeCommand
|
||||||
|
public DelegateCommand ToggleRealtimeCommand { get; }
|
||||||
|
|
||||||
public void ResetMeasurementState()
|
public void ResetMeasurementState()
|
||||||
{
|
{
|
||||||
MeasurePoint1 = null;
|
MeasurePoint1 = null;
|
||||||
@@ -151,10 +203,40 @@ namespace XplorePlane.ViewModels
|
|||||||
{
|
{
|
||||||
ImageSource = _mainViewportService.CurrentDisplayImage;
|
ImageSource = _mainViewportService.CurrentDisplayImage;
|
||||||
|
|
||||||
|
// Task 5.8: Sync IsRealtimeEnabled from service
|
||||||
|
_isRealtimeEnabled = _mainViewportService.IsRealtimeDisplayEnabled;
|
||||||
|
RaisePropertyChanged(nameof(IsRealtimeEnabled));
|
||||||
|
|
||||||
|
// Task 5.8: Sync _isCncRunning from service and raise IsAnimatedSwitchEnabled
|
||||||
|
_isCncRunning = _mainViewportService.IsCncRunning;
|
||||||
|
RaisePropertyChanged(nameof(IsAnimatedSwitchEnabled));
|
||||||
|
|
||||||
if (updateInfo)
|
if (updateInfo)
|
||||||
{
|
{
|
||||||
ImageInfo = _mainViewportService.CurrentDisplayInfo;
|
ImageInfo = _mainViewportService.CurrentDisplayInfo;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Task 5.6: Handle DetectorStateChanged on background thread, dispatch to UI
|
||||||
|
private void OnDetectorStateChanged(object sender, StateChangedEventArgs<DetectorState> e)
|
||||||
|
{
|
||||||
|
Application.Current?.Dispatcher?.BeginInvoke(new Action(() =>
|
||||||
|
{
|
||||||
|
IsDetectorConnected = e.NewValue.IsConnected;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task 5.7: Handle DetectorDisconnectedEvent on UI thread
|
||||||
|
private void OnDetectorDisconnectedForUI()
|
||||||
|
{
|
||||||
|
if (_isCncRunning)
|
||||||
|
{
|
||||||
|
MessageBox.Show(
|
||||||
|
"探测器已断连,CNC 已自动停止。请检查探测器连接后再继续操作。",
|
||||||
|
"探测器断连警告",
|
||||||
|
MessageBoxButton.OK,
|
||||||
|
MessageBoxImage.Warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user