using Microsoft.Extensions.Logging.Abstractions; 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 ScheduledRecycleSchedulerTests { private static readonly DateTime T0 = new(2026, 4, 19, 0, 0, 0, DateTimeKind.Utc); private static readonly TimeSpan Weekly = TimeSpan.FromDays(7); [Theory] [InlineData(DriverTier.A)] [InlineData(DriverTier.B)] public void TierAOrB_Ctor_Throws(DriverTier tier) { var supervisor = new FakeSupervisor(); Should.Throw(() => new ScheduledRecycleScheduler( tier, Weekly, T0, supervisor, NullLogger.Instance)); } [Fact] public void ZeroOrNegativeInterval_Throws() { var supervisor = new FakeSupervisor(); Should.Throw(() => new ScheduledRecycleScheduler( DriverTier.C, TimeSpan.Zero, T0, supervisor, NullLogger.Instance)); Should.Throw(() => new ScheduledRecycleScheduler( DriverTier.C, TimeSpan.FromSeconds(-1), T0, supervisor, NullLogger.Instance)); } [Fact] public async Task Tick_BeforeNextRecycle_NoOp() { var supervisor = new FakeSupervisor(); var sch = new ScheduledRecycleScheduler(DriverTier.C, Weekly, T0, supervisor, NullLogger.Instance); var fired = await sch.TickAsync(T0 + TimeSpan.FromDays(6), CancellationToken.None); fired.ShouldBeFalse(); supervisor.RecycleCount.ShouldBe(0); } [Fact] public async Task Tick_AtOrAfterNextRecycle_FiresOnce_AndAdvances() { var supervisor = new FakeSupervisor(); var sch = new ScheduledRecycleScheduler(DriverTier.C, Weekly, T0, supervisor, NullLogger.Instance); var fired = await sch.TickAsync(T0 + Weekly + TimeSpan.FromMinutes(1), CancellationToken.None); fired.ShouldBeTrue(); supervisor.RecycleCount.ShouldBe(1); sch.NextRecycleUtc.ShouldBe(T0 + Weekly + Weekly); } [Fact] public async Task RequestRecycleNow_Fires_Immediately_WithoutAdvancingSchedule() { var supervisor = new FakeSupervisor(); var sch = new ScheduledRecycleScheduler(DriverTier.C, Weekly, T0, supervisor, NullLogger.Instance); var nextBefore = sch.NextRecycleUtc; await sch.RequestRecycleNowAsync("memory hard-breach", CancellationToken.None); supervisor.RecycleCount.ShouldBe(1); supervisor.LastReason.ShouldBe("memory hard-breach"); sch.NextRecycleUtc.ShouldBe(nextBefore, "ad-hoc recycle doesn't shift the cron schedule"); } [Fact] public async Task MultipleFires_AcrossTicks_AdvanceOneIntervalEach() { var supervisor = new FakeSupervisor(); var sch = new ScheduledRecycleScheduler(DriverTier.C, TimeSpan.FromDays(1), T0, supervisor, NullLogger.Instance); await sch.TickAsync(T0 + TimeSpan.FromDays(1) + TimeSpan.FromHours(1), CancellationToken.None); await sch.TickAsync(T0 + TimeSpan.FromDays(2) + TimeSpan.FromHours(1), CancellationToken.None); await sch.TickAsync(T0 + TimeSpan.FromDays(3) + TimeSpan.FromHours(1), CancellationToken.None); supervisor.RecycleCount.ShouldBe(3); sch.NextRecycleUtc.ShouldBe(T0 + TimeSpan.FromDays(4)); } private sealed class FakeSupervisor : IDriverSupervisor { public string DriverInstanceId => "tier-c-fake"; public int RecycleCount { get; private set; } public string? LastReason { get; private set; } public Task RecycleAsync(string reason, CancellationToken cancellationToken) { RecycleCount++; LastReason = reason; return Task.CompletedTask; } } }