using System.Collections.Concurrent; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.AbCip; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; /// /// PR abcip-5.1 — unit tests for HSBY paired-IP role probing. Drives two fake-runtime /// gateways (primary + partner), forces each to return a chosen WallClockTime.SyncStatus /// value, asserts diagnostics + the device-state /// ActiveAddress resolves to the expected chassis under each split-state combination. /// [Trait("Category", "Unit")] public sealed class AbCipHsbyTests { // ---- Pure value mapping ---- [Theory] [InlineData(0L, HsbyRole.Standby)] // WallClockTime.SyncStatus matrix [InlineData(1L, HsbyRole.Active)] [InlineData(2L, HsbyRole.Disqualified)] [InlineData(99L, HsbyRole.Unknown)] // out-of-range integer public void MapValueToRole_handles_WallClockTime_SyncStatus_matrix(long raw, HsbyRole expected) { AbCipHsbyRoleProber.MapValueToRole(raw, "WallClockTime.SyncStatus").ShouldBe(expected); } [Theory] [InlineData(0L, HsbyRole.Standby)] // bit 0 = 0 → Standby [InlineData(1L, HsbyRole.Active)] // bit 0 = 1 → Active [InlineData(2L, HsbyRole.Standby)] // bit 0 = 0 → Standby (2 = 0b10) [InlineData(3L, HsbyRole.Active)] // bit 0 = 1 → Active (3 = 0b11) public void MapValueToRole_handles_S34_bitmask_fallback(long raw, HsbyRole expected) { AbCipHsbyRoleProber.MapValueToRole(raw, "S:34").ShouldBe(expected); } [Fact] public void MapValueToRole_returns_Unknown_for_null_raw() { AbCipHsbyRoleProber.MapValueToRole(null, "WallClockTime.SyncStatus").ShouldBe(HsbyRole.Unknown); } // ---- ProbeAsync against fake runtime ---- [Fact] public async Task ProbeAsync_returns_Active_when_runtime_decodes_one() { var rt = new FakeAbCipTag(MakeParams("WallClockTime.SyncStatus")) { Value = 1 }; var role = await AbCipHsbyRoleProber.ProbeAsync(rt, "WallClockTime.SyncStatus", CancellationToken.None); role.ShouldBe(HsbyRole.Active); } [Fact] public async Task ProbeAsync_returns_Unknown_when_read_throws() { var rt = new FakeAbCipTag(MakeParams("WallClockTime.SyncStatus")) { ThrowOnRead = true }; var role = await AbCipHsbyRoleProber.ProbeAsync(rt, "WallClockTime.SyncStatus", CancellationToken.None); role.ShouldBe(HsbyRole.Unknown); } [Fact] public async Task ProbeAsync_returns_Unknown_on_non_zero_status() { var rt = new FakeAbCipTag(MakeParams("WallClockTime.SyncStatus")) { Value = 1, Status = -1 }; var role = await AbCipHsbyRoleProber.ProbeAsync(rt, "WallClockTime.SyncStatus", CancellationToken.None); role.ShouldBe(HsbyRole.Unknown); } // ---- End-to-end driver loop ---- [Fact] public async Task Primary_active_partner_standby_resolves_ActiveAddress_to_primary() { var (drv, _) = await BuildHsbyDriverAsync(primaryRoleValue: 1, partnerRoleValue: 0); try { await WaitForRoleAsync(drv, "ab://10.0.0.5/1,0"); var state = drv.GetDeviceState("ab://10.0.0.5/1,0").ShouldNotBeNull(); state.ActiveAddress.ShouldBe("ab://10.0.0.5/1,0"); state.PrimaryRole.ShouldBe(HsbyRole.Active); state.PartnerRole.ShouldBe(HsbyRole.Standby); } finally { await drv.ShutdownAsync(CancellationToken.None); } } [Fact] public async Task Both_active_primary_wins_and_warning_is_emitted() { var warnings = new ConcurrentQueue(); var (drv, _) = await BuildHsbyDriverAsync(primaryRoleValue: 1, partnerRoleValue: 1, warningSink: warnings.Enqueue); try { await WaitForRoleAsync(drv, "ab://10.0.0.5/1,0"); var state = drv.GetDeviceState("ab://10.0.0.5/1,0").ShouldNotBeNull(); state.ActiveAddress.ShouldBe("ab://10.0.0.5/1,0", "split-brain ties must resolve to primary deterministically"); state.PrimaryRole.ShouldBe(HsbyRole.Active); state.PartnerRole.ShouldBe(HsbyRole.Active); warnings.ShouldContain(w => w.Contains("split-brain", StringComparison.OrdinalIgnoreCase)); } finally { await drv.ShutdownAsync(CancellationToken.None); } } [Fact] public async Task Both_standby_clears_ActiveAddress() { var (drv, _) = await BuildHsbyDriverAsync(primaryRoleValue: 0, partnerRoleValue: 0); try { // Let the loop tick at least once + sample the role state. await WaitForAsync(() => drv.GetDeviceState("ab://10.0.0.5/1,0")?.PrimaryRole != HsbyRole.Unknown); var state = drv.GetDeviceState("ab://10.0.0.5/1,0").ShouldNotBeNull(); state.ActiveAddress.ShouldBeNull( "neither chassis Active means no routing target — PR abcip-5.2 will fault writes here"); state.PrimaryRole.ShouldBe(HsbyRole.Standby); state.PartnerRole.ShouldBe(HsbyRole.Standby); } finally { await drv.ShutdownAsync(CancellationToken.None); } } [Fact] public async Task Primary_read_fails_and_partner_active_routes_to_partner() { var factory = new FakeAbCipTagFactory { Customise = p => p.Gateway == "10.0.0.5" ? new FakeAbCipTag(p) { ThrowOnRead = true } : new FakeAbCipTag(p) { Value = 1 }, }; var drv = BuildDriver(factory); await drv.InitializeAsync("{}", CancellationToken.None); try { await WaitForRoleAsync(drv, "ab://10.0.0.6/1,0"); var state = drv.GetDeviceState("ab://10.0.0.5/1,0").ShouldNotBeNull(); state.ActiveAddress.ShouldBe("ab://10.0.0.6/1,0"); state.PrimaryRole.ShouldBe(HsbyRole.Unknown); state.PartnerRole.ShouldBe(HsbyRole.Active); } finally { await drv.ShutdownAsync(CancellationToken.None); } } [Fact] public async Task Hsby_disabled_skips_role_probing_entirely() { var factory = new FakeAbCipTagFactory(); var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [ new AbCipDeviceOptions( "ab://10.0.0.5/1,0", PartnerHostAddress: "ab://10.0.0.6/1,0", Hsby: new AbCipHsbyOptions { Enabled = false }), ], Probe = new AbCipProbeOptions { Enabled = false }, }, "drv-hsby-off", factory); try { await drv.InitializeAsync("{}", CancellationToken.None); await Task.Delay(150); var state = drv.GetDeviceState("ab://10.0.0.5/1,0").ShouldNotBeNull(); state.PrimaryRole.ShouldBe(HsbyRole.Unknown); state.PartnerRole.ShouldBe(HsbyRole.Unknown); state.ActiveAddress.ShouldBeNull(); // Factory must not have been used since Hsby.Enabled = false + probe disabled. factory.Tags.ShouldBeEmpty(); } finally { await drv.ShutdownAsync(CancellationToken.None); } } [Fact] public async Task Diagnostics_surface_HsbyActive_and_role_codes() { var (drv, _) = await BuildHsbyDriverAsync(primaryRoleValue: 1, partnerRoleValue: 0); try { await WaitForRoleAsync(drv, "ab://10.0.0.5/1,0"); var diag = drv.GetHealth().Diagnostics.ShouldNotBeNull(); diag.ShouldContainKey("AbCip.HsbyActive"); diag["AbCip.HsbyActive"].ShouldBe(1); // primary is the active chassis diag["AbCip.HsbyPrimaryRole"].ShouldBe((int)HsbyRole.Active); diag["AbCip.HsbyPartnerRole"].ShouldBe((int)HsbyRole.Standby); } finally { await drv.ShutdownAsync(CancellationToken.None); } } // ---- DTO round-trip ---- [Fact] public async Task DTO_json_round_trip_preserves_PartnerHostAddress_and_Hsby() { const string json = """ { "Devices": [ { "HostAddress": "ab://10.0.0.5/1,0", "PartnerHostAddress": "ab://10.0.0.6/1,0", "Hsby": { "Enabled": true, "RoleTagAddress": "S:34", "ProbeIntervalMs": 5000 } } ] } """; var driver = AbCipDriverFactoryExtensions.CreateInstance("drv-roundtrip", json); try { // Initialise so the device map is populated, then read back via GetDeviceState. await driver.InitializeAsync(json, CancellationToken.None); var state = driver.GetDeviceState("ab://10.0.0.5/1,0").ShouldNotBeNull(); state.Options.PartnerHostAddress.ShouldBe("ab://10.0.0.6/1,0"); state.Options.Hsby.ShouldNotBeNull(); state.Options.Hsby!.Enabled.ShouldBeTrue(); state.Options.Hsby.RoleTagAddress.ShouldBe("S:34"); state.Options.Hsby.ProbeInterval.ShouldBe(TimeSpan.FromMilliseconds(5000)); } finally { await driver.ShutdownAsync(CancellationToken.None); } } // ---- Helpers ---- private static AbCipDriver BuildDriver(FakeAbCipTagFactory factory, Action? warningSink = null) => new AbCipDriver(new AbCipDriverOptions { Devices = [ new AbCipDeviceOptions( "ab://10.0.0.5/1,0", PartnerHostAddress: "ab://10.0.0.6/1,0", Hsby: new AbCipHsbyOptions { Enabled = true, RoleTagAddress = "WallClockTime.SyncStatus", ProbeInterval = TimeSpan.FromMilliseconds(50), }), ], Probe = new AbCipProbeOptions { Enabled = false }, OnWarning = warningSink, }, "drv-hsby", factory); private static async Task<(AbCipDriver Driver, FakeAbCipTagFactory Factory)> BuildHsbyDriverAsync(int primaryRoleValue, int partnerRoleValue, Action? warningSink = null) { 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, warningSink); await drv.InitializeAsync("{}", CancellationToken.None); return (drv, factory); } private static AbCipTagCreateParams MakeParams(string tagName) => new( Gateway: "10.0.0.5", Port: 44818, CipPath: "1,0", LibplctagPlcAttribute: "ControlLogix", TagName: tagName, Timeout: TimeSpan.FromSeconds(2)); private static Task WaitForRoleAsync(AbCipDriver drv, string expectedActive) => WaitForAsync(() => drv.GetDeviceState("ab://10.0.0.5/1,0")?.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); } }