using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Resilience; namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Resilience; [Trait("Category", "Unit")] public sealed class AlarmSurfaceInvokerTests { private static readonly DriverResilienceOptions TierAOptions = new() { Tier = DriverTier.A }; [Fact] public async Task SubscribeAsync_EmptyList_ReturnsEmpty_WithoutDriverCall() { var driver = new FakeAlarmSource(); var surface = NewSurface(driver, defaultHost: "h"); var handles = await surface.SubscribeAsync([], CancellationToken.None); handles.Count.ShouldBe(0); driver.SubscribeCallCount.ShouldBe(0); } [Fact] public async Task SubscribeAsync_SingleHost_RoutesThroughDefaultHost() { var driver = new FakeAlarmSource(); var surface = NewSurface(driver, defaultHost: "h1"); var handles = await surface.SubscribeAsync(["src-1", "src-2"], CancellationToken.None); handles.Count.ShouldBe(1); driver.SubscribeCallCount.ShouldBe(1); driver.LastSubscribedIds.ShouldBe(["src-1", "src-2"]); } [Fact] public async Task SubscribeAsync_MultiHost_FansOutByResolvedHost() { var driver = new FakeAlarmSource(); var resolver = new StubResolver(new Dictionary { ["src-1"] = "plc-a", ["src-2"] = "plc-b", ["src-3"] = "plc-a", }); var surface = NewSurface(driver, defaultHost: "default-ignored", resolver: resolver); var handles = await surface.SubscribeAsync(["src-1", "src-2", "src-3"], CancellationToken.None); handles.Count.ShouldBe(2); // one per distinct host driver.SubscribeCallCount.ShouldBe(2); // one driver call per host } [Fact] public async Task AcknowledgeAsync_DoesNotRetry_OnFailure() { var driver = new FakeAlarmSource { AcknowledgeShouldThrow = true }; var surface = NewSurface(driver, defaultHost: "h1"); await Should.ThrowAsync(() => surface.AcknowledgeAsync([new AlarmAcknowledgeRequest("s", "c", null)], CancellationToken.None)); driver.AcknowledgeCallCount.ShouldBe(1, "AlarmAcknowledge must not retry — decision #143"); } [Fact] public async Task SubscribeAsync_Retries_Transient_Failures() { var driver = new FakeAlarmSource { SubscribeFailuresBeforeSuccess = 2 }; var surface = NewSurface(driver, defaultHost: "h1"); await surface.SubscribeAsync(["src"], CancellationToken.None); driver.SubscribeCallCount.ShouldBe(3, "AlarmSubscribe retries by default — decision #143"); } private static AlarmSurfaceInvoker NewSurface( IAlarmSource driver, string defaultHost, IPerCallHostResolver? resolver = null) { var builder = new DriverResiliencePipelineBuilder(); var invoker = new CapabilityInvoker(builder, "drv-1", () => TierAOptions); return new AlarmSurfaceInvoker(invoker, driver, defaultHost, resolver); } private sealed class FakeAlarmSource : IAlarmSource { public int SubscribeCallCount { get; private set; } public int AcknowledgeCallCount { get; private set; } public int SubscribeFailuresBeforeSuccess { get; set; } public bool AcknowledgeShouldThrow { get; set; } public IReadOnlyList LastSubscribedIds { get; private set; } = []; public Task SubscribeAlarmsAsync( IReadOnlyList sourceNodeIds, CancellationToken cancellationToken) { SubscribeCallCount++; LastSubscribedIds = sourceNodeIds; if (SubscribeCallCount <= SubscribeFailuresBeforeSuccess) throw new InvalidOperationException("transient"); return Task.FromResult(new StubHandle($"h-{SubscribeCallCount}")); } public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) => Task.CompletedTask; public Task AcknowledgeAsync( IReadOnlyList acknowledgements, CancellationToken cancellationToken) { AcknowledgeCallCount++; if (AcknowledgeShouldThrow) throw new InvalidOperationException("ack boom"); return Task.CompletedTask; } public event EventHandler? OnAlarmEvent { add { } remove { } } } private sealed record StubHandle(string DiagnosticId) : IAlarmSubscriptionHandle; private sealed class StubResolver(Dictionary map) : IPerCallHostResolver { public string ResolveHost(string fullReference) => map[fullReference]; } }