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); /// Verifies constructor throws for Tier A or B. /// The driver tier to test. [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)); } /// Verifies constructor throws for zero or negative intervals. [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)); } /// Verifies Tick before the next recycle time is a no-op. [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); } /// Verifies Tick at or after the next recycle time fires once and advances. [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); } /// Verifies RequestRecycleNow fires immediately without advancing the schedule. [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"); } /// Verifies multiple ticks across the recycle interval each advance by one interval. [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)); } /// Fake driver supervisor for testing. private sealed class FakeSupervisor : IDriverSupervisor { /// Gets the driver instance ID. public string DriverInstanceId => "tier-c-fake"; /// Gets the number of times RecycleAsync was called. public int RecycleCount { get; private set; } /// Gets the reason from the most recent recycle call. public string? LastReason { get; private set; } /// Simulates a driver recycle operation. /// The reason for the recycle. /// Cancellation token. /// A completed task. public Task RecycleAsync(string reason, CancellationToken cancellationToken) { RecycleCount++; LastReason = reason; return Task.CompletedTask; } } }