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; /// /// Exercises the per-call host resolver contract against the shared /// + — one /// dead PLC behind a multi-device driver must NOT open the breaker for healthy sibling /// PLCs (decision #144). /// [Trait("Category", "Unit")] public sealed class PerCallHostResolverDispatchTests { private sealed class StaticResolver : IPerCallHostResolver { private readonly Dictionary _map; public StaticResolver(Dictionary map) => _map = map; public string ResolveHost(string fullReference) => _map.TryGetValue(fullReference, out var host) ? host : string.Empty; } [Fact] public async Task DeadPlc_DoesNotOpenBreaker_For_HealthyPlc_With_Resolver() { // Two PLCs behind one driver. Dead PLC keeps failing; healthy PLC must keep serving. var builder = new DriverResiliencePipelineBuilder(); var options = new DriverResilienceOptions { Tier = DriverTier.B }; var invoker = new CapabilityInvoker(builder, "drv-modbus", () => options); var resolver = new StaticResolver(new Dictionary { ["tag-on-dead"] = "plc-dead", ["tag-on-alive"] = "plc-alive", }); var threshold = options.Resolve(DriverCapability.Read).BreakerFailureThreshold; for (var i = 0; i < threshold + 3; i++) { await Should.ThrowAsync(async () => await invoker.ExecuteAsync( DriverCapability.Read, hostName: resolver.ResolveHost("tag-on-dead"), _ => throw new InvalidOperationException("plc-dead unreachable"), CancellationToken.None)); } // Healthy PLC's pipeline is in a different bucket; the first call should succeed // without hitting the dead-PLC breaker. var aliveAttempts = 0; await invoker.ExecuteAsync( DriverCapability.Read, hostName: resolver.ResolveHost("tag-on-alive"), _ => { aliveAttempts++; return ValueTask.FromResult("ok"); }, CancellationToken.None); aliveAttempts.ShouldBe(1, "decision #144 — per-PLC isolation keeps healthy PLCs serving"); } [Fact] public void Resolver_EmptyString_Treated_As_Single_Host_Fallback() { var resolver = new StaticResolver(new Dictionary { ["tag-unknown"] = "", }); resolver.ResolveHost("tag-unknown").ShouldBe(""); resolver.ResolveHost("not-in-map").ShouldBe("", "unknown refs return empty so dispatch falls back to single-host"); } [Fact] public async Task WithoutResolver_SameHost_Shares_One_Pipeline() { // Without a resolver all calls share the DriverInstanceId pipeline — that's the // pre-decision-#144 behavior single-host drivers should keep. var builder = new DriverResiliencePipelineBuilder(); var options = new DriverResilienceOptions { Tier = DriverTier.A }; var invoker = new CapabilityInvoker(builder, "drv-single", () => options); await invoker.ExecuteAsync(DriverCapability.Read, "drv-single", _ => ValueTask.FromResult("a"), CancellationToken.None); await invoker.ExecuteAsync(DriverCapability.Read, "drv-single", _ => ValueTask.FromResult("b"), CancellationToken.None); builder.CachedPipelineCount.ShouldBe(1, "single-host drivers share one pipeline"); } [Fact] public async Task WithResolver_TwoHosts_Get_Two_Pipelines() { var builder = new DriverResiliencePipelineBuilder(); var options = new DriverResilienceOptions { Tier = DriverTier.B }; var invoker = new CapabilityInvoker(builder, "drv-modbus", () => options); var resolver = new StaticResolver(new Dictionary { ["tag-a"] = "plc-a", ["tag-b"] = "plc-b", }); await invoker.ExecuteAsync(DriverCapability.Read, resolver.ResolveHost("tag-a"), _ => ValueTask.FromResult(1), CancellationToken.None); await invoker.ExecuteAsync(DriverCapability.Read, resolver.ResolveHost("tag-b"), _ => ValueTask.FromResult(2), CancellationToken.None); builder.CachedPipelineCount.ShouldBe(2, "each host keyed on its own pipeline"); } }