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; /// /// PR abcip-5.2 — unit tests for HSBY failover routing in /// . Drives a paired-IP HSBY device through /// primary→partner role flips via the FakeAbCipTagFactory's Customise hook + /// asserts: /// /// returns the address of the /// currently-Active chassis (and the configured primary when HSBY is off / /// both Standby). /// The per-device runtime cache is invalidated on flip — disposed handles /// prove the failover handler ran. /// drops cached values for the device so /// the partner pays the full round-trip on next write. /// AbCip.HsbyFailoverCount in driver-diagnostics increments per flip. /// Multiple flips count correctly. /// /// [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); } /// /// Snapshot of the live primary + partner role-tag fakes the factory has handed /// out, keyed by gateway. Populated by the Customise hook on the /// via a side-effecting lambda; the /// dict alone is insufficient because both /// chassis use the same role-tag TagName + the dict overwrites on the second /// create. /// 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); } /// /// Mutate the live primary / partner role-tag fakes' Value so the next /// probe-loop tick reads the new role. Probe loop reuses one runtime per chassis /// once initialised, so direct mutation of is /// sufficient — no re-create required. /// 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 condition, TimeSpan? timeout = null) { var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(2)); while (!condition() && DateTime.UtcNow < deadline) await Task.Delay(20); } }