Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/PerCallHostResolverDispatchTests.cs
T
Joseph Doherty bd6c0b4d3d docs: complete XML doc comments via fixdocs (2757 to 131 findings)
Add missing <returns>/<param>/<summary>/<typeparam> tags and clean up
misused inheritdoc across 481 files so the documented API surface is
complete. Documentation-only (zero code lines changed). The 131 remaining
findings are inheritdoc-style warnings deliberately left to preserve
hand-written implementation rationale (plan-decision notes, race-condition
explanations).
2026-06-03 12:34:34 -04:00

123 lines
5.4 KiB
C#

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;
/// <summary>
/// Exercises the per-call host resolver contract against the shared
/// <see cref="DriverResiliencePipelineBuilder"/> + <see cref="CapabilityInvoker"/> — one
/// dead PLC behind a multi-device driver must NOT open the breaker for healthy sibling
/// PLCs (decision #144).
/// </summary>
[Trait("Category", "Unit")]
public sealed class PerCallHostResolverDispatchTests
{
private sealed class StaticResolver : IPerCallHostResolver
{
private readonly Dictionary<string, string> _map;
/// <summary>Initializes a new instance of StaticResolver with a predefined mapping.</summary>
/// <param name="map">The mapping of full references to host names.</param>
public StaticResolver(Dictionary<string, string> map) => _map = map;
/// <inheritdoc />
public string ResolveHost(string fullReference) =>
_map.TryGetValue(fullReference, out var host) ? host : string.Empty;
}
/// <summary>Verifies that a dead PLC does not open the breaker for healthy PLCs when using a per-call resolver.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[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<string, string>
{
["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<Exception>(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");
}
/// <summary>Verifies that empty string from resolver is treated as single-host fallback.</summary>
[Fact]
public void Resolver_EmptyString_Treated_As_Single_Host_Fallback()
{
var resolver = new StaticResolver(new Dictionary<string, string>
{
["tag-unknown"] = "",
});
resolver.ResolveHost("tag-unknown").ShouldBe("");
resolver.ResolveHost("not-in-map").ShouldBe("", "unknown refs return empty so dispatch falls back to single-host");
}
/// <summary>Verifies that without a resolver, the same host shares one resilience pipeline.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[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");
}
/// <summary>Verifies that with a resolver, different hosts get separate resilience pipelines.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[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<string, string>
{
["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");
}
}