b66972b6f5
On a wire the native PCCC path is proven only for N-file reads/writes and one F8 read (ab_server). An absent transport key now resolves to Libplctag for every family; explicit Native still wins and still warns once per instance on PLC-5/LogixPccc. AdminUI dropdown, CLI help, evidence claims (PcccFileType, AbLegacy.md wire-evidence table, release note) corrected: S:0 (default probe) and B/T/C/R/S/L/ST/A/O/I are unverified natively. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
230 lines
11 KiB
C#
230 lines
11 KiB
C#
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}}]}""";
|
|
|
|
private static string Config(string? transportJson, params string[] families)
|
|
{
|
|
var devices = string.Join(",", families.Select((f, i) =>
|
|
$"{{\"hostAddress\":\"ab://10.0.0.{i + 1}:44818/1,0\",\"plcFamily\":\"{f}\"}}"));
|
|
var transport = transportJson is null ? "" : $"\"transport\":{transportJson},";
|
|
return $"{{{transport}\"devices\":[{devices}]}}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Transport values select the factory type; absent parses to null ("use the default") and the driver
|
|
/// resolves that to Libplctag (final review I3 — Native is opt-in). An explicit Native still wins.
|
|
/// </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)
|
|
{
|
|
var parsed = AbLegacyDriverFactoryExtensions.ParseOptions("ab1", Config(json));
|
|
if (json is null) parsed.Transport.ShouldBeNull();
|
|
else parsed.Transport.ShouldBe(expected);
|
|
|
|
var driver = AbLegacyDriverFactoryExtensions.CreateInstance("ab1", Config(json));
|
|
driver.EffectiveTransport.ShouldBe(expected);
|
|
if (expected == AbLegacyTransport.Native)
|
|
driver.TagFactory.ShouldBeOfType<NativeAbLegacyTagFactory>();
|
|
else
|
|
driver.TagFactory.ShouldBeOfType<LibplctagLegacyTagFactory>();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Final review I3: an absent key resolves EVERY family to libplctag — the SLC/MicroLogix rows are the
|
|
/// ones that change (they defaulted Native before), so they are the falsification rows: reverting the
|
|
/// resolver to "Native unless PLC-5/LogixPccc" turns them red.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData("Plc5")]
|
|
[InlineData("LogixPccc")]
|
|
[InlineData("Slc500", "Plc5")]
|
|
[InlineData("Slc500")]
|
|
[InlineData("MicroLogix")]
|
|
[InlineData("Slc500", "MicroLogix")]
|
|
public void Absent_transport_resolves_libplctag_for_every_family(params string[] families)
|
|
{
|
|
var json = Config(null, families);
|
|
AbLegacyTransportResolver.Resolve(AbLegacyDriverFactoryExtensions.ParseOptions("ab1", json))
|
|
.ShouldBe(AbLegacyTransport.Libplctag);
|
|
|
|
var driver = AbLegacyDriverFactoryExtensions.CreateInstance("ab1", json);
|
|
driver.EffectiveTransport.ShouldBe(AbLegacyTransport.Libplctag);
|
|
driver.TagFactory.ShouldBeOfType<LibplctagLegacyTagFactory>();
|
|
}
|
|
|
|
/// <summary>The init-log rendering names the default as such, and an explicit choice as configured.</summary>
|
|
[Fact]
|
|
public void Describe_names_default_and_configured()
|
|
{
|
|
AbLegacyTransportResolver.Describe(null).ShouldBe("Libplctag (default)");
|
|
AbLegacyTransportResolver.Describe(AbLegacyTransport.Native).ShouldBe("Native (configured)");
|
|
AbLegacyTransportResolver.Describe(AbLegacyTransport.Libplctag).ShouldBe("Libplctag (configured)");
|
|
}
|
|
|
|
/// <summary>An explicit value always wins, for every family.</summary>
|
|
[Theory]
|
|
[InlineData("\"Native\"", "Plc5", AbLegacyTransport.Native)]
|
|
[InlineData("\"Native\"", "LogixPccc", AbLegacyTransport.Native)]
|
|
[InlineData("\"Native\"", "Slc500", AbLegacyTransport.Native)]
|
|
[InlineData("\"Libplctag\"", "Slc500", AbLegacyTransport.Libplctag)]
|
|
public void Explicit_transport_wins_over_the_exclusion(string json, string family, AbLegacyTransport expected)
|
|
{
|
|
var driver = AbLegacyDriverFactoryExtensions.CreateInstance("ab1", Config(json, family));
|
|
driver.EffectiveTransport.ShouldBe(expected);
|
|
}
|
|
|
|
/// <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 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>(
|
|
() => AbLegacyDriverFactoryExtensions.ParseOptions("ab1", Config("\"7\"")))
|
|
.Message.ShouldContain("unknown Transport '7'");
|
|
AbLegacyDriverFactoryExtensions.ParseOptions("ab1", Config("\"Native\"")).Transport.ShouldBe(AbLegacyTransport.Native);
|
|
}
|
|
|
|
/// <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;
|
|
_ = 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 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 resolves an ABSENT transport through the same resolver as the driver: an SLC 500 with no
|
|
/// key takes libplctag (no native failure text) exactly as a PLC-5 does, while an explicit Native on the
|
|
/// same listener produces the native text — the control that proves the text discriminates.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Probe_with_no_transport_resolves_like_the_driver()
|
|
{
|
|
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 family) =>
|
|
$$"""{"devices":[{"hostAddress":"ab://127.0.0.1:{{port}}/1,0","plcFamily":"{{family}}"}]}""";
|
|
var nativeCfg =
|
|
$$"""{"transport":"Native","devices":[{"hostAddress":"ab://127.0.0.1:{{port}}/1,0","plcFamily":"Slc500"}]}""";
|
|
var probe = new AbLegacyDriverProbe();
|
|
|
|
var slc = await probe.ProbeAsync(Cfg("Slc500"), TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
|
|
var plc5 = await probe.ProbeAsync(Cfg("Plc5"), TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
|
|
var native = await probe.ProbeAsync(nativeCfg, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
|
|
listener.Stop();
|
|
|
|
AbLegacyTransportResolver.Resolve(AbLegacyDriverFactoryExtensions.ParseOptions("p", Cfg("Slc500")))
|
|
.ShouldBe(AbLegacyTransport.Libplctag);
|
|
AbLegacyTransportResolver.Resolve(AbLegacyDriverFactoryExtensions.ParseOptions("p", Cfg("Plc5")))
|
|
.ShouldBe(AbLegacyTransport.Libplctag);
|
|
slc.Ok.ShouldBeFalse();
|
|
slc.Message!.ShouldNotContain("Connection lost");
|
|
plc5.Message!.ShouldNotContain("Connection lost");
|
|
native.Message!.ShouldContain("PCCC handshake failed: 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");
|
|
}
|
|
}
|