feat(ablegacy): Transport option selects native or libplctag across driver, probe and CLI

This commit is contained in:
Joseph Doherty
2026-10-07 16:57:20 -04:00
parent f2377251af
commit 5fd9ff941e
9 changed files with 217 additions and 7 deletions
@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using CliFx.Infrastructure;
using Shouldly;
@@ -158,4 +160,44 @@ public sealed class BuildOptionsTests
options.Devices.Count.ShouldBe(1);
options.Devices[0].PlcFamily.ShouldBe(AbLegacyPlcFamily.Plc5);
}
/// <summary>Verifies the transport defaults to libplctag and --transport reaches the options.</summary>
[Fact]
public void BuildOptions_defaults_to_libplctag_and_forwards_transport()
{
var def = new TestCommand { Gateway = "ab://192.168.1.20/1,0" };
def.Build([]).Transport.ShouldBe(AbLegacyTransport.Libplctag);
var native = new TestCommand { Gateway = "ab://192.168.1.20/1,0", Transport = AbLegacyTransport.Native };
native.Build([]).Transport.ShouldBe(AbLegacyTransport.Native);
}
/// <summary>Verifies CliFx really parses <c>--transport native</c> (case-insensitive) into the property.</summary>
[Theory]
[InlineData("native", AbLegacyTransport.Native)]
[InlineData("Native", AbLegacyTransport.Native)]
[InlineData("libplctag", AbLegacyTransport.Libplctag)]
public async Task Transport_flag_is_parsed_by_the_cli(string value, AbLegacyTransport expected)
{
CapturingCommand.Captured = null;
using var console = new FakeInMemoryConsole();
var app = new CliApplicationBuilder().AddCommand(typeof(CapturingCommand)).UseConsole(console).Build();
var exit = await app.RunAsync(["capture-transport", "--gateway", "ab://1.2.3.4/1,0", "--transport", value]);
exit.ShouldBe(0, console.ReadErrorString());
CapturingCommand.Captured.ShouldBe(expected);
}
[Command("capture-transport")]
private sealed class CapturingCommand : AbLegacyCommandBase
{
public static AbLegacyTransport? Captured;
public override System.Threading.Tasks.ValueTask ExecuteAsync(IConsole console)
{
Captured = BuildOptions([]).Transport;
return default;
}
}
}
@@ -0,0 +1,117 @@
using System.Net;
using System.Net.Sockets;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>The per-driver <c>Transport</c> switch: config parsing, driver factory selection and the probe.</summary>
[Trait("Category", "Unit")]
public sealed class AbLegacyTransportSwitchTests
{
private const string Device = """{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"Slc500"}""";
private static string Config(string? transportJson) =>
$$"""{{{(transportJson is null ? "" : $"\"transport\":{transportJson},")}}"devices":[{{Device}}]}""";
/// <summary>Transport values select the factory type; absent defaults to libplctag.</summary>
[Theory]
[InlineData("\"Native\"", AbLegacyTransport.Native)]
[InlineData("\"native\"", AbLegacyTransport.Native)]
[InlineData("\"Libplctag\"", AbLegacyTransport.Libplctag)]
[InlineData(null, AbLegacyTransport.Libplctag)]
public void Config_transport_selects_the_factory(string? json, AbLegacyTransport expected)
{
AbLegacyDriverFactoryExtensions.ParseOptions("ab1", Config(json)).Transport.ShouldBe(expected);
var driver = AbLegacyDriverFactoryExtensions.CreateInstance("ab1", Config(json));
if (expected == AbLegacyTransport.Native)
driver.TagFactory.ShouldBeOfType<NativeAbLegacyTagFactory>();
else
driver.TagFactory.ShouldBeOfType<LibplctagLegacyTagFactory>();
}
/// <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>(
() => AbLegacyDriverFactoryExtensions.ParseOptions("ab1", Config("\"Bogus\"")));
bad.Message.ShouldContain("unknown Transport 'Bogus'");
var existing = Should.Throw<InvalidOperationException>(() => AbLegacyDriverFactoryExtensions.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 explicitly injected factory wins over the option.</summary>
[Fact]
public void Injected_factory_wins_over_the_option()
{
var fake = new FakeAbLegacyTagFactory();
var driver = new AbLegacyDriver(new AbLegacyDriverOptions { Transport = AbLegacyTransport.Native }, "ab1", fake);
driver.TagFactory.ShouldBeSameAs(fake);
}
/// <summary>
/// Reinit does NOT swap transports (by design): the factory is 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 = AbLegacyDriverFactoryExtensions.CreateInstance("ab1", Config("\"Libplctag\""));
var before = driver.TagFactory;
await driver.ReinitializeAsync(Config("\"Native\""), TestContext.Current.CancellationToken);
driver.TagFactory.ShouldBeSameAs(before);
driver.TagFactory.ShouldBeOfType<LibplctagLegacyTagFactory>();
}
/// <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;
var accept = Task.Run(async () =>
{
while (true)
{
try { (await listener.AcceptTcpClientAsync(TestContext.Current.CancellationToken)).Dispose(); }
catch { return; }
}
});
string Cfg(string t) =>
$$"""{"transport":"{{t}}","devices":[{"hostAddress":"ab://127.0.0.1:{{port}}/1,0"}]}""";
var probe = new AbLegacyDriverProbe();
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();
// "Connection lost" is the managed EncapsulationClient's wording; libplctag reports a bare status name.
native.Message!.ShouldContain("PCCC 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 AbLegacyDriverProbe().ProbeAsync(
Config("\"Bogus\""), TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken);
r.Ok.ShouldBeFalse();
r.Message!.ShouldContain("Config JSON is invalid");
}
}