using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Stability; namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Stability; [Trait("Category", "Unit")] public sealed class WedgeDetectorTests { private static readonly DateTime Now = new(2026, 4, 19, 12, 0, 0, DateTimeKind.Utc); private static readonly TimeSpan Threshold = TimeSpan.FromSeconds(120); [Fact] public void SubSixtySecondThreshold_ClampsToSixty() { var detector = new WedgeDetector(TimeSpan.FromSeconds(10)); detector.Threshold.ShouldBe(TimeSpan.FromSeconds(60)); } [Fact] public void Unhealthy_Driver_AlwaysNotApplicable() { var detector = new WedgeDetector(Threshold); var demand = new DemandSignal(BulkheadDepth: 5, ActiveMonitoredItems: 10, QueuedHistoryReads: 0, LastProgressUtc: Now.AddMinutes(-10)); detector.Classify(DriverState.Faulted, demand, Now).ShouldBe(WedgeVerdict.NotApplicable); detector.Classify(DriverState.Degraded, demand, Now).ShouldBe(WedgeVerdict.NotApplicable); detector.Classify(DriverState.Initializing, demand, Now).ShouldBe(WedgeVerdict.NotApplicable); } [Fact] public void Idle_Subscription_Only_StaysIdle() { // Idle driver: bulkhead 0, monitored items 0, no history reads queued. // Even if LastProgressUtc is ancient, the verdict is Idle, not Faulted. var detector = new WedgeDetector(Threshold); var demand = new DemandSignal(0, 0, 0, Now.AddHours(-12)); detector.Classify(DriverState.Healthy, demand, Now).ShouldBe(WedgeVerdict.Idle); } [Fact] public void PendingWork_WithRecentProgress_StaysHealthy() { var detector = new WedgeDetector(Threshold); var demand = new DemandSignal(BulkheadDepth: 2, ActiveMonitoredItems: 0, QueuedHistoryReads: 0, LastProgressUtc: Now.AddSeconds(-30)); detector.Classify(DriverState.Healthy, demand, Now).ShouldBe(WedgeVerdict.Healthy); } [Fact] public void PendingWork_WithStaleProgress_IsFaulted() { var detector = new WedgeDetector(Threshold); var demand = new DemandSignal(BulkheadDepth: 2, ActiveMonitoredItems: 0, QueuedHistoryReads: 0, LastProgressUtc: Now.AddMinutes(-5)); detector.Classify(DriverState.Healthy, demand, Now).ShouldBe(WedgeVerdict.Faulted); } [Fact] public void MonitoredItems_Active_ButNoRecentPublish_IsFaulted() { // Subscription-only driver with live MonitoredItems but no publish progress within threshold // is a real wedge — this is the case the previous "no successful Read" formulation used // to miss (no reads ever happen). var detector = new WedgeDetector(Threshold); var demand = new DemandSignal(BulkheadDepth: 0, ActiveMonitoredItems: 5, QueuedHistoryReads: 0, LastProgressUtc: Now.AddMinutes(-10)); detector.Classify(DriverState.Healthy, demand, Now).ShouldBe(WedgeVerdict.Faulted); } [Fact] public void MonitoredItems_Active_WithFreshPublish_StaysHealthy() { var detector = new WedgeDetector(Threshold); var demand = new DemandSignal(BulkheadDepth: 0, ActiveMonitoredItems: 5, QueuedHistoryReads: 0, LastProgressUtc: Now.AddSeconds(-10)); detector.Classify(DriverState.Healthy, demand, Now).ShouldBe(WedgeVerdict.Healthy); } [Fact] public void HistoryBackfill_SlowButMakingProgress_StaysHealthy() { // Slow historian backfill — QueuedHistoryReads > 0 but progress advances within threshold. var detector = new WedgeDetector(Threshold); var demand = new DemandSignal(BulkheadDepth: 0, ActiveMonitoredItems: 0, QueuedHistoryReads: 50, LastProgressUtc: Now.AddSeconds(-60)); detector.Classify(DriverState.Healthy, demand, Now).ShouldBe(WedgeVerdict.Healthy); } [Fact] public void WriteOnlyBurst_StaysIdle_WhenBulkheadEmpty() { // A write-only driver that just finished a burst: bulkhead drained, no subscriptions, no // history reads. Idle — the previous formulation would have faulted here because no // reads were succeeding even though the driver is perfectly healthy. var detector = new WedgeDetector(Threshold); var demand = new DemandSignal(0, 0, 0, Now.AddMinutes(-30)); detector.Classify(DriverState.Healthy, demand, Now).ShouldBe(WedgeVerdict.Idle); } [Fact] public void DemandSignal_HasPendingWork_TrueForAnyNonZeroCounter() { new DemandSignal(1, 0, 0, Now).HasPendingWork.ShouldBeTrue(); new DemandSignal(0, 1, 0, Now).HasPendingWork.ShouldBeTrue(); new DemandSignal(0, 0, 1, Now).HasPendingWork.ShouldBeTrue(); new DemandSignal(0, 0, 0, Now).HasPendingWork.ShouldBeFalse(); } }