using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Stability; using ZB.MOM.WW.OtOpcUa.Server.Hosting; namespace ZB.MOM.WW.OtOpcUa.Server.Tests; [Trait("Category", "Unit")] public sealed class ScheduledRecycleHostedServiceTests { private static readonly DateTime T0 = new(2026, 4, 19, 0, 0, 0, DateTimeKind.Utc); private sealed class FakeClock : TimeProvider { public DateTime Utc { get; set; } = T0; public override DateTimeOffset GetUtcNow() => new(Utc, TimeSpan.Zero); } private sealed class FakeSupervisor : IDriverSupervisor { public string DriverInstanceId => "tier-c-fake"; public int RecycleCount { get; private set; } public Task RecycleAsync(string reason, CancellationToken cancellationToken) { RecycleCount++; return Task.CompletedTask; } } private sealed class ThrowingSupervisor : IDriverSupervisor { public string DriverInstanceId => "tier-c-throws"; public Task RecycleAsync(string reason, CancellationToken cancellationToken) => throw new InvalidOperationException("supervisor unavailable"); } [Fact] public async Task TickOnce_BeforeInterval_DoesNotFire() { var clock = new FakeClock(); var supervisor = new FakeSupervisor(); var scheduler = new ScheduledRecycleScheduler( DriverTier.C, TimeSpan.FromMinutes(5), T0, supervisor, NullLogger.Instance); var host = new ScheduledRecycleHostedService(NullLogger.Instance, clock); host.AddScheduler(scheduler); clock.Utc = T0.AddMinutes(1); await host.TickOnceAsync(CancellationToken.None); supervisor.RecycleCount.ShouldBe(0); host.TickCount.ShouldBe(1); } [Fact] public async Task TickOnce_AfterInterval_Fires() { var clock = new FakeClock(); var supervisor = new FakeSupervisor(); var scheduler = new ScheduledRecycleScheduler( DriverTier.C, TimeSpan.FromMinutes(5), T0, supervisor, NullLogger.Instance); var host = new ScheduledRecycleHostedService(NullLogger.Instance, clock); host.AddScheduler(scheduler); clock.Utc = T0.AddMinutes(6); await host.TickOnceAsync(CancellationToken.None); supervisor.RecycleCount.ShouldBe(1); } [Fact] public async Task TickOnce_MultipleTicks_AccumulateCount() { var clock = new FakeClock(); var host = new ScheduledRecycleHostedService(NullLogger.Instance, clock); await host.TickOnceAsync(CancellationToken.None); await host.TickOnceAsync(CancellationToken.None); await host.TickOnceAsync(CancellationToken.None); host.TickCount.ShouldBe(3); } [Fact] public async Task AddScheduler_AfterStart_Throws() { var host = new ScheduledRecycleHostedService(NullLogger.Instance); using var cts = new CancellationTokenSource(); cts.Cancel(); await host.StartAsync(cts.Token); // flips _started true even with cancelled token await host.StopAsync(CancellationToken.None); var scheduler = new ScheduledRecycleScheduler( DriverTier.C, TimeSpan.FromMinutes(5), DateTime.UtcNow, new FakeSupervisor(), NullLogger.Instance); Should.Throw(() => host.AddScheduler(scheduler)); } [Fact] public async Task OneSchedulerThrowing_DoesNotStopOthers() { var clock = new FakeClock(); var good = new FakeSupervisor(); var bad = new ThrowingSupervisor(); var goodSch = new ScheduledRecycleScheduler( DriverTier.C, TimeSpan.FromMinutes(5), T0, good, NullLogger.Instance); var badSch = new ScheduledRecycleScheduler( DriverTier.C, TimeSpan.FromMinutes(5), T0, bad, NullLogger.Instance); var host = new ScheduledRecycleHostedService(NullLogger.Instance, clock); host.AddScheduler(badSch); host.AddScheduler(goodSch); clock.Utc = T0.AddMinutes(6); await host.TickOnceAsync(CancellationToken.None); good.RecycleCount.ShouldBe(1, "a faulting scheduler must not poison its neighbours"); } [Fact] public void SchedulerCount_MatchesAdded() { var host = new ScheduledRecycleHostedService(NullLogger.Instance); var sup = new FakeSupervisor(); host.AddScheduler(new ScheduledRecycleScheduler(DriverTier.C, TimeSpan.FromMinutes(5), DateTime.UtcNow, sup, NullLogger.Instance)); host.AddScheduler(new ScheduledRecycleScheduler(DriverTier.C, TimeSpan.FromMinutes(10), DateTime.UtcNow, sup, NullLogger.Instance)); host.SchedulerCount.ShouldBe(2); } [Fact] public async Task EmptyScheduler_List_TicksCleanly() { var clock = new FakeClock(); var host = new ScheduledRecycleHostedService(NullLogger.Instance, clock); // No registered schedulers — tick is a no-op + counter still advances. await host.TickOnceAsync(CancellationToken.None); host.TickCount.ShouldBe(1); } }