@@ -0,0 +1,47 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-5.2 — integration scaffold for HSBY failover routing through
|
||||
/// <see cref="AbCipDriver.ResolveHost"/>. Skipped by default because the paired
|
||||
/// fixture (controllogix-secondary <c>ab_server</c> instance + <c>hsby-mux</c>
|
||||
/// sidecar that flips the role tag on demand) is not yet stable in the Docker
|
||||
/// compose layout. The scaffold lives here so:
|
||||
/// <list type="bullet">
|
||||
/// <item>The trait is discoverable by <c>dotnet test --filter "Category=Hsby"</c>.</item>
|
||||
/// <item>The companion E2E script (<c>scripts/e2e/test-abcip-hsby.ps1</c>) has a
|
||||
/// paired surface already wired in tests when an operator stands up the fixture
|
||||
/// manually.</item>
|
||||
/// <item>A future PR can flip the skip into a real assertion without restructuring
|
||||
/// the test layout.</item>
|
||||
/// </list>
|
||||
/// The unit-level coverage in <c>AbCipHsbyFailoverTests</c> (in the unit tests
|
||||
/// project) exercises the active-address-routing + cache-invalidation contract in
|
||||
/// full against the FakeAbCipTagFactory; this scaffold is just the wire-level shape.
|
||||
/// </summary>
|
||||
[Trait("Category", "Hsby")]
|
||||
[Trait("Requires", "AbServer")]
|
||||
public sealed class AbCipHsbyFailoverTests
|
||||
{
|
||||
[AbServerFact]
|
||||
public Task ResolveHost_routes_to_partner_after_role_flip_through_hsby_mux()
|
||||
{
|
||||
// The paired-fixture compose service (controllogix + controllogix-secondary +
|
||||
// hsby-mux sidecar at http://localhost:7080) is not yet wired. When it ships,
|
||||
// the test body will:
|
||||
// 1. POST {"active": "primary"} to hsby-mux → assert ResolveHost = primary
|
||||
// gateway via a CLI read.
|
||||
// 2. POST {"active": "partner"} → wait for the probe loop to catch up →
|
||||
// assert ResolveHost = partner gateway via a second CLI read.
|
||||
// 3. Assert AbCip.HsbyFailoverCount on the driver's diagnostics
|
||||
// ≥ 1 by reading the driver-diagnostics RPC through the OPC UA Admin
|
||||
// surface.
|
||||
Assert.Skip("HSBY paired fixture (controllogix-secondary + hsby-mux sidecar) " +
|
||||
"not yet promoted out of scaffold. Run scripts/e2e/test-abcip-hsby.ps1 against a " +
|
||||
"manually-stood-up paired fixture when verifying this PR end-to-end.");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user