Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipHsbyFailoverTests.cs
2026-04-26 08:13:41 -04:00

374 lines
15 KiB
C#

using System.Collections.Concurrent;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// PR abcip-5.2 — unit tests for HSBY failover routing in
/// <see cref="AbCipDriver.ResolveHost"/>. Drives a paired-IP HSBY device through
/// primary→partner role flips via the FakeAbCipTagFactory's <c>Customise</c> hook +
/// asserts:
/// <list type="bullet">
/// <item><see cref="AbCipDriver.ResolveHost"/> returns the address of the
/// currently-Active chassis (and the configured primary when HSBY is off /
/// both Standby).</item>
/// <item>The per-device runtime cache is invalidated on flip — disposed handles
/// prove the failover handler ran.</item>
/// <item><see cref="AbCipWriteCoalescer"/> drops cached values for the device so
/// the partner pays the full round-trip on next write.</item>
/// <item><c>AbCip.HsbyFailoverCount</c> in driver-diagnostics increments per flip.</item>
/// <item>Multiple flips count correctly.</item>
/// </list>
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipHsbyFailoverTests
{
private const string Primary = "ab://10.0.0.5/1,0";
private const string Partner = "ab://10.0.0.6/1,0";
// ---- ResolveHost routing ----
[Fact]
public async Task ResolveHost_returns_partner_when_partner_active()
{
var (drv, _) = await BuildHsbyDriverAsync(primaryRoleValue: 0, partnerRoleValue: 1);
try
{
await WaitForActiveAsync(drv, Partner);
var resolved = drv.ResolveHost("Motor01_Speed");
// Tag isn't registered; resolver still falls through ResolveActiveHostFor on
// the first configured device, which has the partner active.
resolved.ShouldBe(Partner);
}
finally
{
await drv.ShutdownAsync(CancellationToken.None);
}
}
[Fact]
public async Task ResolveHost_returns_primary_when_primary_active()
{
var (drv, _) = await BuildHsbyDriverAsync(primaryRoleValue: 1, partnerRoleValue: 0);
try
{
await WaitForActiveAsync(drv, Primary);
drv.ResolveHost("Motor01_Speed").ShouldBe(Primary);
}
finally
{
await drv.ShutdownAsync(CancellationToken.None);
}
}
[Fact]
public async Task Toggling_role_flips_ResolveHost_output()
{
var (factory, tracker) = BuildTrackingFactory(initialPrimary: 1, initialPartner: 0);
var drv = BuildDriver(factory);
await drv.InitializeAsync("{}", CancellationToken.None);
try
{
await WaitForActiveAsync(drv, Primary);
drv.ResolveHost("anything").ShouldBe(Primary);
FlipRoles(tracker, newPrimary: 0, newPartner: 1);
await WaitForActiveAsync(drv, Partner);
drv.ResolveHost("anything").ShouldBe(Partner);
}
finally
{
await drv.ShutdownAsync(CancellationToken.None);
}
}
[Fact]
public async Task ResolveHost_falls_back_to_primary_when_both_standby()
{
var (drv, _) = await BuildHsbyDriverAsync(primaryRoleValue: 0, partnerRoleValue: 0);
try
{
// Wait for the role state to settle so we know the loop ticked at least once.
await WaitForAsync(() => drv.GetDeviceState(Primary)?.PrimaryRole != HsbyRole.Unknown);
drv.ResolveHost("anything").ShouldBe(Primary,
"neither chassis Active means ActiveAddress is null; ResolveHost falls back to the configured primary");
}
finally
{
await drv.ShutdownAsync(CancellationToken.None);
}
}
[Fact]
public async Task ResolveHost_ignores_ActiveAddress_when_Hsby_disabled()
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions(
Primary,
PartnerHostAddress: Partner,
Hsby: new AbCipHsbyOptions { Enabled = false }),
],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-hsby-off-resolve", factory);
try
{
await drv.InitializeAsync("{}", CancellationToken.None);
// Manually plant an ActiveAddress that conflicts with the primary; ResolveHost
// must still pick the primary because Hsby is disabled.
var state = drv.GetDeviceState(Primary).ShouldNotBeNull();
state.ActiveAddress = Partner;
drv.ResolveHost("anything").ShouldBe(Primary);
}
finally
{
await drv.ShutdownAsync(CancellationToken.None);
}
}
// ---- Cache invalidation on flip ----
[Fact]
public async Task Failover_invalidates_runtime_cache_and_increments_counter()
{
var (factory, tracker) = BuildTrackingFactory(initialPrimary: 1, initialPartner: 0);
var drv = BuildDriverWithTag(factory, "Motor01_Speed");
await drv.InitializeAsync("{}", CancellationToken.None);
try
{
await WaitForActiveAsync(drv, Primary);
// Force a per-tag runtime to be created against the primary.
var initialReads = await drv.ReadAsync(["Motor01_Speed"], CancellationToken.None);
initialReads.Count.ShouldBe(1);
var state = drv.GetDeviceState(Primary).ShouldNotBeNull();
state.Runtimes.ShouldContainKey("Motor01_Speed");
var runtimeBeforeFlip = (FakeAbCipTag)state.Runtimes["Motor01_Speed"];
runtimeBeforeFlip.CreationParams.Gateway.ShouldBe("10.0.0.5");
// Flip — primary→Standby, partner→Active.
FlipRoles(tracker, newPrimary: 0, newPartner: 1);
await WaitForActiveAsync(drv, Partner);
// The pre-flip runtime should have been disposed by the failover handler.
runtimeBeforeFlip.Disposed.ShouldBeTrue();
// Cache should be empty until the next read repopulates it.
state.Runtimes.ShouldNotContainKey("Motor01_Speed");
// Diagnostics counter ticked.
var diag = drv.GetHealth().Diagnostics.ShouldNotBeNull();
diag.ShouldContainKey("AbCip.HsbyFailoverCount");
diag["AbCip.HsbyFailoverCount"].ShouldBeGreaterThanOrEqualTo(1);
// Next read recreates against the partner gateway.
var afterReads = await drv.ReadAsync(["Motor01_Speed"], CancellationToken.None);
afterReads.Count.ShouldBe(1);
state.Runtimes.ShouldContainKey("Motor01_Speed");
var runtimeAfterFlip = (FakeAbCipTag)state.Runtimes["Motor01_Speed"];
runtimeAfterFlip.CreationParams.Gateway.ShouldBe("10.0.0.6",
"post-flip runtime must target the partner's gateway");
runtimeAfterFlip.ShouldNotBeSameAs(runtimeBeforeFlip);
}
finally
{
await drv.ShutdownAsync(CancellationToken.None);
}
}
[Fact]
public async Task Failover_resets_write_coalescer_for_device()
{
var (factory, tracker) = BuildTrackingFactory(initialPrimary: 1, initialPartner: 0);
var drv = BuildDriverWithTag(factory, "Motor01_Speed");
await drv.InitializeAsync("{}", CancellationToken.None);
try
{
await WaitForActiveAsync(drv, Primary);
// Seed the coalescer cache for this device + tag. We poke it directly via
// the test seam so we don't depend on the multi-write planner accepting our
// synthetic Motor01_Speed definition.
var def = new AbCipTagDefinition(
Name: "Motor01_Speed",
DeviceHostAddress: Primary,
TagPath: "Motor01_Speed",
DataType: AbCipDataType.DInt,
Writable: true,
WriteOnChange: true);
drv.WriteCoalescer.Record(Primary, def, 42);
drv.WriteCoalescer.ShouldSuppress(Primary, def, 42).ShouldBeTrue(
"baseline: identical re-write must be suppressed pre-failover");
FlipRoles(tracker, newPrimary: 0, newPartner: 1);
await WaitForActiveAsync(drv, Partner);
// The cache for this device was cleared so the same write is no longer suppressed.
drv.WriteCoalescer.ShouldSuppress(Primary, def, 42).ShouldBeFalse(
"failover must drop cached known-written values; partner needs the wire round-trip");
}
finally
{
await drv.ShutdownAsync(CancellationToken.None);
}
}
[Fact]
public async Task Multiple_flips_each_increment_HsbyFailoverCount()
{
var (factory, tracker) = BuildTrackingFactory(initialPrimary: 1, initialPartner: 0);
var drv = BuildDriver(factory);
await drv.InitializeAsync("{}", CancellationToken.None);
try
{
await WaitForActiveAsync(drv, Primary);
var diagBaseline = drv.GetHealth().Diagnostics.ShouldNotBeNull();
var startCount = diagBaseline.TryGetValue("AbCip.HsbyFailoverCount", out var v) ? v : 0;
// Flip 1: primary→partner
FlipRoles(tracker, newPrimary: 0, newPartner: 1);
await WaitForActiveAsync(drv, Partner);
// Flip 2: partner→primary
FlipRoles(tracker, newPrimary: 1, newPartner: 0);
await WaitForActiveAsync(drv, Primary);
// Flip 3: primary→partner again
FlipRoles(tracker, newPrimary: 0, newPartner: 1);
await WaitForActiveAsync(drv, Partner);
var diag = drv.GetHealth().Diagnostics.ShouldNotBeNull();
diag["AbCip.HsbyFailoverCount"].ShouldBeGreaterThanOrEqualTo(startCount + 3);
}
finally
{
await drv.ShutdownAsync(CancellationToken.None);
}
}
// ---- Helpers ----
private static AbCipDriver BuildDriver(FakeAbCipTagFactory factory) =>
new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions(
Primary,
PartnerHostAddress: Partner,
Hsby: new AbCipHsbyOptions
{
Enabled = true,
RoleTagAddress = "WallClockTime.SyncStatus",
ProbeInterval = TimeSpan.FromMilliseconds(40),
}),
],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-hsby-failover", factory);
private static AbCipDriver BuildDriverWithTag(FakeAbCipTagFactory factory, string tagName) =>
new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions(
Primary,
PartnerHostAddress: Partner,
Hsby: new AbCipHsbyOptions
{
Enabled = true,
RoleTagAddress = "WallClockTime.SyncStatus",
ProbeInterval = TimeSpan.FromMilliseconds(40),
}),
],
Tags =
[
new AbCipTagDefinition(
Name: tagName,
DeviceHostAddress: Primary,
TagPath: tagName,
DataType: AbCipDataType.DInt,
Writable: true),
],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-hsby-failover-tag", factory);
private static async Task<(AbCipDriver Driver, FakeAbCipTagFactory Factory)>
BuildHsbyDriverAsync(int primaryRoleValue, int partnerRoleValue)
{
var factory = new FakeAbCipTagFactory
{
Customise = p => p.Gateway == "10.0.0.5"
? new FakeAbCipTag(p) { Value = primaryRoleValue }
: new FakeAbCipTag(p) { Value = partnerRoleValue },
};
var drv = BuildDriver(factory);
await drv.InitializeAsync("{}", CancellationToken.None);
return (drv, factory);
}
/// <summary>
/// Snapshot of the live primary + partner role-tag fakes the factory has handed
/// out, keyed by gateway. Populated by the <c>Customise</c> hook on the
/// <see cref="FakeAbCipTagFactory"/> via a side-effecting lambda; the
/// <see cref="FakeAbCipTagFactory.Tags"/> dict alone is insufficient because both
/// chassis use the same role-tag TagName + the dict overwrites on the second
/// create.
/// </summary>
private sealed class HsbyRoleTagTracker
{
public FakeAbCipTag? Primary { get; set; }
public FakeAbCipTag? Partner { get; set; }
}
private static (FakeAbCipTagFactory Factory, HsbyRoleTagTracker Tracker)
BuildTrackingFactory(int initialPrimary, int initialPartner)
{
var tracker = new HsbyRoleTagTracker();
var factory = new FakeAbCipTagFactory();
factory.Customise = p =>
{
if (p.TagName == "WallClockTime.SyncStatus")
{
var fake = new FakeAbCipTag(p)
{
Value = p.Gateway == "10.0.0.5" ? initialPrimary : initialPartner,
};
if (p.Gateway == "10.0.0.5") tracker.Primary = fake;
else tracker.Partner = fake;
return fake;
}
// Non-role-tag handles (e.g. per-tag runtimes) — return a default fake.
return new FakeAbCipTag(p) { Value = 0 };
};
return (factory, tracker);
}
/// <summary>
/// Mutate the live primary / partner role-tag fakes' <c>Value</c> so the next
/// probe-loop tick reads the new role. Probe loop reuses one runtime per chassis
/// once initialised, so direct mutation of <see cref="FakeAbCipTag.Value"/> is
/// sufficient — no re-create required.
/// </summary>
private static void FlipRoles(HsbyRoleTagTracker tracker, int newPrimary, int newPartner)
{
if (tracker.Primary is not null) tracker.Primary.Value = newPrimary;
if (tracker.Partner is not null) tracker.Partner.Value = newPartner;
}
private static Task WaitForActiveAsync(AbCipDriver drv, string expectedActive) =>
WaitForAsync(() => drv.GetDeviceState(Primary)?.ActiveAddress == expectedActive);
private static async Task WaitForAsync(Func<bool> condition, TimeSpan? timeout = null)
{
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(2));
while (!condition() && DateTime.UtcNow < deadline)
await Task.Delay(20);
}
}