using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Server.Redundancy; namespace ZB.MOM.WW.OtOpcUa.Server.Tests; [Trait("Category", "Unit")] public sealed class RecoveryStateManagerTests { private static readonly DateTime T0 = new(2026, 4, 19, 12, 0, 0, DateTimeKind.Utc); private sealed class FakeTimeProvider : TimeProvider { public DateTime Utc { get; set; } = T0; public override DateTimeOffset GetUtcNow() => new(Utc, TimeSpan.Zero); } [Fact] public void NeverFaulted_DwellIsAutomaticallyMet() { var mgr = new RecoveryStateManager(); mgr.IsDwellMet().ShouldBeTrue(); } [Fact] public void AfterFault_Only_IsDwellMet_Returns_True_ButCallerDoesntQueryDuringFaulted() { // Documented semantics: IsDwellMet is only consulted when selfHealthy=true (i.e. the // node has recovered into Healthy). During Faulted the coordinator short-circuits on // the self-health check and never calls IsDwellMet. So returning true here is harmless; // the test captures the intent so a future "return false during Faulted" tweak has to // deliberately change this test first. var mgr = new RecoveryStateManager(); mgr.MarkFaulted(); mgr.IsDwellMet().ShouldBeTrue(); } [Fact] public void AfterRecovery_NoWitness_DwellNotMet_EvenAfterElapsed() { var clock = new FakeTimeProvider(); var mgr = new RecoveryStateManager(dwellTime: TimeSpan.FromSeconds(60), timeProvider: clock); mgr.MarkFaulted(); mgr.MarkRecovered(); clock.Utc = T0.AddSeconds(120); mgr.IsDwellMet().ShouldBeFalse("dwell elapsed but no publish witness — must NOT escape Recovering band"); } [Fact] public void AfterRecovery_WitnessButTooSoon_DwellNotMet() { var clock = new FakeTimeProvider(); var mgr = new RecoveryStateManager(dwellTime: TimeSpan.FromSeconds(60), timeProvider: clock); mgr.MarkFaulted(); mgr.MarkRecovered(); mgr.RecordPublishWitness(); clock.Utc = T0.AddSeconds(30); mgr.IsDwellMet().ShouldBeFalse("witness ok but dwell 30s < 60s"); } [Fact] public void AfterRecovery_Witness_And_DwellElapsed_Met() { var clock = new FakeTimeProvider(); var mgr = new RecoveryStateManager(dwellTime: TimeSpan.FromSeconds(60), timeProvider: clock); mgr.MarkFaulted(); mgr.MarkRecovered(); mgr.RecordPublishWitness(); clock.Utc = T0.AddSeconds(61); mgr.IsDwellMet().ShouldBeTrue(); } [Fact] public void ReFault_ResetsWitness_AndDwellClock() { var clock = new FakeTimeProvider(); var mgr = new RecoveryStateManager(dwellTime: TimeSpan.FromSeconds(60), timeProvider: clock); mgr.MarkFaulted(); mgr.MarkRecovered(); mgr.RecordPublishWitness(); clock.Utc = T0.AddSeconds(61); mgr.IsDwellMet().ShouldBeTrue(); mgr.MarkFaulted(); mgr.MarkRecovered(); clock.Utc = T0.AddSeconds(100); // re-entered Recovering, no new witness mgr.IsDwellMet().ShouldBeFalse("new recovery needs its own witness"); } }