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");
}
}