374 lines
15 KiB
C#
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);
|
|
}
|
|
}
|