Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipHostProbeTests.cs
Joseph Doherty ac14ba9664 AB CIP PR 8 — IHostConnectivityProbe + IPerCallHostResolver. Per-device probe loop — when AbCipProbeOptions.Enabled + ProbeTagPath are configured, InitializeAsync kicks off one probe task per device that periodically reads the probe tag (lazy-init on first attempt, re-init on wire failure so destroyed native handles get recreated rather than silently staying broken), transitions Running on status==0 or Stopped on non-zero status / exception, raises OnHostStatusChanged with the device HostAddress as the host-name key. TransitionDeviceState guards against spurious same-state events under a per-device lock. ShutdownAsync cancels + disposes each probe's CTS + its captured runtime. DeviceState record gains ProbeLock / HostState / HostStateChangedUtc / ProbeCts / ProbeInitialized fields. IHostConnectivityProbe.GetHostStatuses returns one HostConnectivityStatus per device with the current state + last-change timestamp, surfaced to Admin /hosts per plan decision #144. IPerCallHostResolver.ResolveHost maps a tag full-reference to its DeviceHostAddress via the _tagsByName dict populated at Initialize time, which means UDT member full-references (Motor1.Speed synthesised by PR 6) resolve to the parent UDT's device without extra bookkeeping. Unknown references fall back to the first configured device's host address (invoker handles the actual mislookup at read time as BadNodeIdUnknown), and when no devices are configured resolver returns DriverInstanceId so the single-host fallback pipeline still works. Matches the plan decision #144 contract — Phase 6.1 resilience keys its bulkhead + breaker on (DriverInstanceId, ResolvedHostName) so a dead PLC trips only its own breaker, healthy siblings keep serving. 10 new unit tests in AbCipHostProbeTests covering GetHostStatuses returning one entry per device, probe success transitioning Unknown → Running, probe exception transitioning to Stopped, Enabled=false skipping the loop (no events + state stays Unknown), null ProbeTagPath skipping the loop, multi-device independent probe behavior (one Running + one Stopped simultaneously), ResolveHost for known tags returning the declared DeviceHostAddress, ResolveHost for unknown ref falling back to first device, ResolveHost falling back to DriverInstanceId when no devices, ResolveHost for UDT member walking to the synthesised member definition. Total AbCip unit tests now 147/147 passing (+10 from PR 7's 137). Full solution builds 0 errors; Modbus + other drivers untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 17:15:10 -04:00

228 lines
8.5 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;
[Trait("Category", "Unit")]
public sealed class AbCipHostProbeTests
{
[Fact]
public async Task GetHostStatuses_returns_one_entry_per_device()
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions("ab://10.0.0.5/1,0"),
new AbCipDeviceOptions("ab://10.0.0.6/1,0"),
],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
var statuses = drv.GetHostStatuses();
statuses.Count.ShouldBe(2);
statuses.Select(s => s.HostName).ShouldBe(["ab://10.0.0.5/1,0", "ab://10.0.0.6/1,0"], ignoreOrder: true);
statuses.ShouldAllBe(s => s.State == HostState.Unknown);
}
[Fact]
public async Task Probe_with_successful_read_transitions_to_Running()
{
var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Status = 0 } };
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Probe = new AbCipProbeOptions
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(100),
Timeout = TimeSpan.FromMilliseconds(50),
ProbeTagPath = "@raw_cpu_type",
},
}, "drv-1", factory);
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitForAsync(() => transitions.Any(t => t.NewState == HostState.Running), TimeSpan.FromSeconds(2));
transitions.Select(t => t.NewState).ShouldContain(HostState.Running);
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Probe_with_read_failure_transitions_to_Stopped()
{
var factory = new FakeAbCipTagFactory
{
Customise = p => new FakeAbCipTag(p) { ThrowOnRead = true },
};
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Probe = new AbCipProbeOptions
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(100),
Timeout = TimeSpan.FromMilliseconds(50),
ProbeTagPath = "@raw_cpu_type",
},
}, "drv-1", factory);
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitForAsync(() => transitions.Any(t => t.NewState == HostState.Stopped), TimeSpan.FromSeconds(2));
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Stopped);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Probe_disabled_when_Enabled_is_false()
{
var factory = new FakeAbCipTagFactory();
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Probe = new AbCipProbeOptions { Enabled = false, ProbeTagPath = "@raw_cpu_type" },
}, "drv-1", factory);
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
await drv.InitializeAsync("{}", CancellationToken.None);
await Task.Delay(300);
transitions.ShouldBeEmpty();
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Unknown);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Probe_skipped_when_ProbeTagPath_is_null()
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Probe = new AbCipProbeOptions { Enabled = true, ProbeTagPath = null },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
await Task.Delay(200);
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Unknown);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Probe_loops_across_multiple_devices_independently()
{
var factory = new FakeAbCipTagFactory
{
// Device A returns ok, Device B throws on read.
Customise = p => p.Gateway == "10.0.0.5"
? new FakeAbCipTag(p)
: new FakeAbCipTag(p) { ThrowOnRead = true },
};
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions("ab://10.0.0.5/1,0"),
new AbCipDeviceOptions("ab://10.0.0.6/1,0"),
],
Probe = new AbCipProbeOptions
{
Enabled = true, Interval = TimeSpan.FromMilliseconds(100),
Timeout = TimeSpan.FromMilliseconds(50), ProbeTagPath = "@raw_cpu_type",
},
}, "drv-1", factory);
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitForAsync(() => transitions.Count >= 2, TimeSpan.FromSeconds(3));
transitions.ShouldContain(t => t.HostName == "ab://10.0.0.5/1,0" && t.NewState == HostState.Running);
transitions.ShouldContain(t => t.HostName == "ab://10.0.0.6/1,0" && t.NewState == HostState.Stopped);
await drv.ShutdownAsync(CancellationToken.None);
}
// ---- IPerCallHostResolver ----
[Fact]
public async Task ResolveHost_returns_declared_device_for_known_tag()
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions("ab://10.0.0.5/1,0"),
new AbCipDeviceOptions("ab://10.0.0.6/1,0"),
],
Tags =
[
new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt),
new AbCipTagDefinition("B", "ab://10.0.0.6/1,0", "B", AbCipDataType.DInt),
],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.ResolveHost("A").ShouldBe("ab://10.0.0.5/1,0");
drv.ResolveHost("B").ShouldBe("ab://10.0.0.6/1,0");
}
[Fact]
public async Task ResolveHost_falls_back_to_first_device_for_unknown_reference()
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.ResolveHost("does-not-exist").ShouldBe("ab://10.0.0.5/1,0");
}
[Fact]
public async Task ResolveHost_falls_back_to_DriverInstanceId_when_no_devices()
{
var drv = new AbCipDriver(new AbCipDriverOptions(), "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.ResolveHost("anything").ShouldBe("drv-1");
}
[Fact]
public async Task ResolveHost_for_UDT_member_walks_to_synthesised_definition()
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.7/1,0")],
Tags =
[
new AbCipTagDefinition("Motor1", "ab://10.0.0.7/1,0", "Motor1", AbCipDataType.Structure,
Members: [new AbCipStructureMember("Speed", AbCipDataType.DInt)]),
],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.ResolveHost("Motor1.Speed").ShouldBe("ab://10.0.0.7/1,0");
}
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!condition() && DateTime.UtcNow < deadline)
await Task.Delay(20);
}
}