Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTransportSwitchTests.cs
T
Joseph Doherty 1806af54f0 fix(ab): Task 22 review r1 — release-note timing and AbLegacy regressions, probe E2E on both transports
- I1: the upgrade restart itself moves every no-key AbCip/AbLegacy driver to Native (every process
  start constructs drivers, incl. boot-from-cache). Docs + CLAUDE.md now say so and tell operators
  to set "transport": "Libplctag" BEFORE upgrading (older builds ignore the unknown key).
- I2: AbLegacy release note leads with the status-bit write refusal (T4:0.DN, C5:0.CU, R6:0.EN, ...)
  on defaulted SLC/MicroLogix drivers and the fallback. The real client status is
  BadCommunicationError, not BadNotSupported (the driver's write catch-all maps the runtime's
  AbTransportException like any transport failure, as #571 does for PLC-5), with handle eviction
  and Degraded health; pinned by a driver-level test (no 0xAA write on the wire, with a positive
  control). ST string byte order stated as unverified. Also corrects the docs' "T4:0/DN" examples:
  the driver's address grammar is "T4:0.DN" ("/DN" is a malformed address).
- I3: DriverProbeHandshakeE2eTests' two AbCip probe tests are a [Theory] over Native and
  Libplctag. Run against ab_server controllogix: all 4 rows pass with identical exact messages.
- M1: documented (docs + resolver XML doc): the AdminUI merges only the probed device, so a
  mixed-family defaulted AbLegacy driver can probe Native while running Libplctag.
- M2: factory ParseEnum rejects undefined integer strings ("7") via Enum.IsDefined (both drivers).
- M3: AbCip driver-level bad value reads "AB CIP config in '<id>' has unknown Transport ..."; the
  named constant is gone (tagName: null now means driver-level).
- M4: the explicit-Native limited-family Warning is once per instance, not per (re)init.
- Nit: EffectiveTransport docs note that injected factories bypass it.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 21:06:40 -04:00

195 lines
8.7 KiB
C#

using System.Net;
using System.Net.Sockets;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>The per-driver <c>Transport</c> switch: config parsing, driver factory selection and the probe.</summary>
[Trait("Category", "Unit")]
public sealed class AbCipTransportSwitchTests
{
private const string Device = """{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"ControlLogix"}""";
private static string Config(string? transportJson) =>
$$"""{{{(transportJson is null ? "" : $"\"transport\":{transportJson},")}}"devices":[{{Device}}]}""";
/// <summary>
/// Transport values select the factory trio; absent parses to null ("use the default") and the driver
/// resolves that to Native (Task 22). An explicit Libplctag still wins.
/// </summary>
[Theory]
[InlineData("\"Native\"", AbCipTransport.Native)]
[InlineData("\"native\"", AbCipTransport.Native)]
[InlineData("\"Libplctag\"", AbCipTransport.Libplctag)]
[InlineData(null, AbCipTransport.Native)]
public void Config_transport_selects_the_factory(string? json, AbCipTransport expected)
{
var parsed = AbCipDriverFactoryExtensions.ParseOptions("ab1", Config(json));
if (json is null) parsed.Transport.ShouldBeNull();
else parsed.Transport.ShouldBe(expected);
var driver = AbCipDriverFactoryExtensions.CreateInstance("ab1", Config(json));
driver.EffectiveTransport.ShouldBe(expected);
if (expected == AbCipTransport.Native)
driver.TagFactory.ShouldBeOfType<NativeAbCipTagFactory>();
else
driver.TagFactory.ShouldBeOfType<LibplctagTagFactory>();
}
/// <summary>The factory trio matches the transport for all three factory kinds.</summary>
[Fact]
public void Factories_For_returns_a_matching_trio()
{
var n = AbCipTransportFactories.For(AbCipTransport.Native);
n.Tags.ShouldBeOfType<NativeAbCipTagFactory>();
n.Enumerators.ShouldBeOfType<NativeAbCipTagEnumeratorFactory>();
n.Templates.ShouldBeOfType<NativeAbCipTemplateReaderFactory>();
var l = AbCipTransportFactories.For(AbCipTransport.Libplctag);
l.Tags.ShouldBeOfType<LibplctagTagFactory>();
l.Enumerators.ShouldBeOfType<LibplctagTagEnumeratorFactory>();
l.Templates.ShouldBeOfType<LibplctagTemplateReaderFactory>();
}
/// <summary>A bad value fails the way an existing bad enum field (PlcFamily) does.</summary>
[Fact]
public void Bad_transport_fails_like_a_bad_PlcFamily()
{
var bad = Should.Throw<InvalidOperationException>(
() => AbCipDriverFactoryExtensions.ParseOptions("ab1", Config("\"Bogus\"")));
bad.Message.ShouldContain("unknown Transport 'Bogus'");
var existing = Should.Throw<InvalidOperationException>(() => AbCipDriverFactoryExtensions.ParseOptions(
"ab1", """{"devices":[{"hostAddress":"ab://10.0.0.1/1,0","plcFamily":"Bogus"}]}"""));
existing.GetType().ShouldBe(bad.GetType());
existing.Message.ShouldContain("unknown PlcFamily 'Bogus'");
}
/// <summary>
/// An undefined integer string is rejected, not silently run on a switch's default arm (Task 22 review
/// r1 M2): <c>Enum.TryParse</c> alone accepts "7". A defined numeric ("1") still parses, as before.
/// </summary>
[Fact]
public void An_undefined_numeric_transport_is_rejected()
{
Should.Throw<InvalidOperationException>(
() => AbCipDriverFactoryExtensions.ParseOptions("ab1", Config("\"7\"")))
.Message.ShouldContain("unknown Transport '7'");
AbCipDriverFactoryExtensions.ParseOptions("ab1", Config("\"Native\"")).Transport.ShouldBe(AbCipTransport.Native);
}
/// <summary>A bad driver-level value names the driver config, not a "tag" (Task 22 review r1 M3).</summary>
[Fact]
public void A_bad_transport_names_the_driver_config()
{
var ex = Should.Throw<InvalidOperationException>(
() => AbCipDriverFactoryExtensions.ParseOptions("ab1", Config("\"Bogus\"")));
ex.Message.ShouldStartWith("AB CIP config in 'ab1' has unknown Transport 'Bogus'");
}
/// <summary>An explicitly injected factory wins over the option.</summary>
[Fact]
public void Injected_factory_wins_over_the_option()
{
var fake = new FakeAbCipTagFactory();
var driver = new AbCipDriver(new AbCipDriverOptions { Transport = AbCipTransport.Native }, "ab1", fake);
driver.TagFactory.ShouldBeSameAs(fake);
}
/// <summary>
/// Reinit does NOT swap transports (by design): the factories are constructor-held, so a changed
/// <c>transport</c> in the reinit JSON is inert. A deploy recreates the instance instead (#516).
/// </summary>
[Fact]
public async Task Reinit_with_a_changed_transport_keeps_the_constructed_transport()
{
var driver = AbCipDriverFactoryExtensions.CreateInstance("ab1", Config("\"Libplctag\""));
var before = driver.TagFactory;
await driver.ReinitializeAsync(Config("\"Native\""), TestContext.Current.CancellationToken);
driver.TagFactory.ShouldBeSameAs(before);
driver.TagFactory.ShouldBeOfType<LibplctagTagFactory>();
}
/// <summary>
/// The probe's Phase 1 TCP preflight rejects a refused port before any runtime exists, so the native
/// path is proven against a listener that accepts TCP and hangs up: only a runtime that really opens
/// an EtherNet/IP session reports the native transport's own failure text.
/// </summary>
[Fact]
public async Task Probe_with_Native_uses_the_native_path()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
_ = Task.Run(async () =>
{
while (true)
{
try { (await listener.AcceptTcpClientAsync(TestContext.Current.CancellationToken)).Dispose(); }
catch { return; }
}
}, TestContext.Current.CancellationToken);
string Cfg(string t) =>
$$"""{"transport":"{{t}}","devices":[{"hostAddress":"ab://127.0.0.1:{{port}}/1,0"}]}""";
var probe = new AbCipDriverProbe();
var native = await probe.ProbeAsync(Cfg("Native"), TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
var lib = await probe.ProbeAsync(Cfg("Libplctag"), TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
listener.Stop();
native.Ok.ShouldBeFalse();
native.Message.ShouldNotBeNull();
native.Message!.ShouldContain("CIP handshake failed: Connection lost");
lib.Message!.ShouldNotContain("Connection lost");
}
/// <summary>
/// The probe resolves an ABSENT transport through the same resolver as the driver: with no key it takes
/// the native path (native failure text), and an explicit Libplctag on the same listener does not.
/// </summary>
[Fact]
public async Task Probe_with_no_transport_resolves_like_the_driver_to_native()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
_ = Task.Run(async () =>
{
while (true)
{
try { (await listener.AcceptTcpClientAsync(TestContext.Current.CancellationToken)).Dispose(); }
catch { return; }
}
}, TestContext.Current.CancellationToken);
var device = $$"""{"hostAddress":"ab://127.0.0.1:{{port}}/1,0"}""";
var probe = new AbCipDriverProbe();
var absent = await probe.ProbeAsync($$"""{"devices":[{{device}}]}""", TimeSpan.FromSeconds(3),
TestContext.Current.CancellationToken);
var lib = await probe.ProbeAsync($$"""{"transport":"Libplctag","devices":[{{device}}]}""", TimeSpan.FromSeconds(3),
TestContext.Current.CancellationToken);
listener.Stop();
AbCipTransportResolver.Resolve(AbCipDriverFactoryExtensions.ParseOptions("p", $$"""{"devices":[{{device}}]}"""))
.ShouldBe(AbCipTransport.Native);
absent.Ok.ShouldBeFalse();
absent.Message!.ShouldContain("CIP handshake failed: Connection lost");
lib.Message!.ShouldNotContain("Connection lost");
}
/// <summary>The probe deserialises with its own string-enum converter; a bad transport is a config error.</summary>
[Fact]
public async Task Probe_with_a_bad_transport_reports_invalid_config()
{
var r = await new AbCipDriverProbe().ProbeAsync(
Config("\"Bogus\""), TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken);
r.Ok.ShouldBeFalse();
r.Message!.ShouldContain("Config JSON is invalid");
}
}