using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Supervisor; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests; [Trait("Category", "Unit")] public sealed class CircuitBreakerTests { [Fact] public void First_three_crashes_within_window_allow_respawn() { var breaker = new CircuitBreaker(); var t0 = new DateTime(2026, 4, 17, 12, 0, 0, DateTimeKind.Utc); breaker.TryRecordCrash(t0, out _).ShouldBeTrue(); breaker.TryRecordCrash(t0.AddSeconds(30), out _).ShouldBeTrue(); breaker.TryRecordCrash(t0.AddSeconds(60), out _).ShouldBeTrue(); } [Fact] public void Fourth_crash_within_window_opens_breaker_with_sticky_alert() { var breaker = new CircuitBreaker(); var t0 = new DateTime(2026, 4, 17, 12, 0, 0, DateTimeKind.Utc); for (var i = 0; i < 3; i++) breaker.TryRecordCrash(t0.AddSeconds(i * 30), out _); breaker.TryRecordCrash(t0.AddSeconds(120), out var remaining).ShouldBeFalse(); remaining.ShouldBe(TimeSpan.FromHours(1)); breaker.StickyAlertActive.ShouldBeTrue(); } [Fact] public void Cooldown_escalates_1h_then_4h_then_manual() { var breaker = new CircuitBreaker(); var t0 = new DateTime(2026, 4, 17, 12, 0, 0, DateTimeKind.Utc); // Open once. for (var i = 0; i < 4; i++) breaker.TryRecordCrash(t0.AddSeconds(i * 30), out _); // Cooldown starts when the breaker opens (the 4th crash, at t0+90s). Jump past 1h from there. var openedAt = t0.AddSeconds(90); var afterFirstCooldown = openedAt.AddHours(1).AddMinutes(1); breaker.TryRecordCrash(afterFirstCooldown, out _).ShouldBeTrue("cooldown elapsed, breaker closes for a try"); // Second trip: within 5 min, breaker opens again with 4h cooldown. The crash that trips // it is the 3rd retry since the cooldown closed (afterFirstCooldown itself counted as 1). breaker.TryRecordCrash(afterFirstCooldown.AddSeconds(30), out _).ShouldBeTrue(); breaker.TryRecordCrash(afterFirstCooldown.AddSeconds(60), out _).ShouldBeTrue(); breaker.TryRecordCrash(afterFirstCooldown.AddSeconds(90), out var cd2).ShouldBeFalse( "4th crash within window reopens the breaker"); cd2.ShouldBe(TimeSpan.FromHours(4)); // Third trip: 4h elapsed, breaker closes for a try, then reopens with MaxValue (manual only). var reopenedAt = afterFirstCooldown.AddSeconds(90); var afterSecondCooldown = reopenedAt.AddHours(4).AddMinutes(1); breaker.TryRecordCrash(afterSecondCooldown, out _).ShouldBeTrue(); breaker.TryRecordCrash(afterSecondCooldown.AddSeconds(30), out _).ShouldBeTrue(); breaker.TryRecordCrash(afterSecondCooldown.AddSeconds(60), out _).ShouldBeTrue(); breaker.TryRecordCrash(afterSecondCooldown.AddSeconds(90), out var cd3).ShouldBeFalse(); cd3.ShouldBe(TimeSpan.MaxValue); } [Fact] public void ManualReset_clears_sticky_alert_and_crash_history() { var breaker = new CircuitBreaker(); var t0 = DateTime.UtcNow; for (var i = 0; i < 4; i++) breaker.TryRecordCrash(t0.AddSeconds(i * 30), out _); breaker.ManualReset(); breaker.StickyAlertActive.ShouldBeFalse(); breaker.TryRecordCrash(t0.AddMinutes(10), out _).ShouldBeTrue(); } }