302 lines
11 KiB
C#
302 lines
11 KiB
C#
using System.Collections.Concurrent;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
|
|
|
/// <summary>
|
|
/// 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 <c>WallClockTime.SyncStatus</c>
|
|
/// value, asserts <see cref="AbCipDriver.GetHealth"/> diagnostics + the device-state
|
|
/// <c>ActiveAddress</c> resolves to the expected chassis under each split-state combination.
|
|
/// </summary>
|
|
[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<string>();
|
|
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<string>? 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<string>? 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<bool> condition, TimeSpan? timeout = null)
|
|
{
|
|
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(2));
|
|
while (!condition() && DateTime.UtcNow < deadline)
|
|
await Task.Delay(20);
|
|
}
|
|
}
|