fe2b0614c8
The libplctag-path positive control asserted 'timed out', but libplctag's internal timeout equals the probe timeout and races the probe's own cancellation; under CPU load it surfaces as 'ErrorTimeout' instead. Accept either libplctag stall outcome (native never produces either: it fails at once with 'Connection lost'). Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
303 lines
14 KiB
C#
303 lines
14 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'");
|
|
// #574: a device-level field must not be reported as a tag named 'device'.
|
|
existing.Message.ShouldNotContain("tag '");
|
|
existing.Message.ShouldStartWith("AB CIP config in 'ab1' has 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>
|
|
/// Positive control for the libplctag path against a listener that accepts TCP and never speaks CIP.
|
|
/// libplctag reports the stall two ways that race on the wall clock: the probe's own cancellation
|
|
/// ("Probe timed out after Ns") or libplctag's internal timeout, which has the same duration and
|
|
/// surfaces as the <c>ErrorTimeout</c> status. Which fires first depends on scheduler load, so both are
|
|
/// the libplctag outcome; the native path never produces either (it fails at once with "Connection lost").
|
|
/// </summary>
|
|
private static void ShouldBeLibplctagTimeout(string? message)
|
|
{
|
|
message.ShouldNotBeNull();
|
|
(message.Contains("timed out", StringComparison.OrdinalIgnoreCase)
|
|
|| message.Contains("ErrorTimeout", StringComparison.Ordinal))
|
|
.ShouldBeTrue($"expected the libplctag stall outcome (timed out / ErrorTimeout) but was: {message}");
|
|
}
|
|
|
|
/// <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");
|
|
// Positive (#574): the libplctag path ran and reported its own outcome (the bounded probe timeout),
|
|
// not merely "something other than the native text".
|
|
ShouldBeLibplctagTimeout(lib.Message);
|
|
}
|
|
|
|
/// <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");
|
|
// Positive (#574): the libplctag path ran and reported its own outcome (the bounded probe timeout),
|
|
// not merely "something other than the native text".
|
|
ShouldBeLibplctagTimeout(lib.Message);
|
|
}
|
|
|
|
/// <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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Final review I1 precedence: explicit key > node default > compiled Native. The pinned rows are
|
|
/// the falsification rows — a resolver that ignores the node default returns Native for them.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData(null, AbCipTransport.Libplctag, AbCipTransport.Libplctag)]
|
|
[InlineData(null, AbCipTransport.Native, AbCipTransport.Native)]
|
|
[InlineData(AbCipTransport.Native, AbCipTransport.Libplctag, AbCipTransport.Native)]
|
|
[InlineData(AbCipTransport.Libplctag, AbCipTransport.Native, AbCipTransport.Libplctag)]
|
|
public void Explicit_key_beats_node_default_beats_compiled_default(
|
|
AbCipTransport? configured, AbCipTransport nodeDefault, AbCipTransport expected)
|
|
{
|
|
AbCipTransportResolver.Resolve(configured, nodeDefault).ShouldBe(expected);
|
|
var json = configured is null ? Config(null) : Config($"\"{configured}\"");
|
|
AbCipDriverFactoryExtensions.CreateInstance("ab1", json, loggerFactory: null, defaultTransport: nodeDefault)
|
|
.EffectiveTransport.ShouldBe(expected);
|
|
}
|
|
|
|
/// <summary>The init-log rendering distinguishes the compiled default from a node pin.</summary>
|
|
[Fact]
|
|
public void Describe_distinguishes_compiled_default_node_default_and_configured()
|
|
{
|
|
AbCipTransportResolver.Describe(null).ShouldBe("Native (default)");
|
|
AbCipTransportResolver.Describe(null, AbCipTransport.Libplctag).ShouldBe("Libplctag (node default)");
|
|
AbCipTransportResolver.Describe(AbCipTransport.Native, AbCipTransport.Libplctag).ShouldBe("Native (configured)");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("Native", true)]
|
|
[InlineData("libplctag", true)]
|
|
[InlineData(" Libplctag ", true)]
|
|
[InlineData("1", false)]
|
|
[InlineData("Native,Libplctag", false)]
|
|
[InlineData("", false)]
|
|
[InlineData("Bogus", false)]
|
|
public void TryParse_accepts_names_only(string raw, bool ok) =>
|
|
AbCipTransportResolver.TryParse(raw, out _).ShouldBe(ok);
|
|
|
|
/// <summary>
|
|
/// The probe honours its node default for an absent key: pinned to Libplctag it does NOT produce the
|
|
/// native failure text against the same listener the unpinned probe does (falsification control).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Probe_with_no_transport_uses_the_node_default()
|
|
{
|
|
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 cfg = $$"""{"devices":[{"hostAddress":"ab://127.0.0.1:{{port}}/1,0"}]}""";
|
|
var pinned = await new AbCipDriverProbe(defaultTransport: AbCipTransport.Libplctag)
|
|
.ProbeAsync(cfg, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
|
|
var unpinned = await new AbCipDriverProbe()
|
|
.ProbeAsync(cfg, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
|
|
listener.Stop();
|
|
|
|
unpinned.Message!.ShouldContain("CIP handshake failed: Connection lost");
|
|
pinned.Ok.ShouldBeFalse();
|
|
pinned.Message!.ShouldNotContain("Connection lost");
|
|
ShouldBeLibplctagTimeout(pinned.Message); // positive: libplctag's own outcome (#574)
|
|
}
|
|
|
|
/// <summary>Gitea #572: <c>browsePageDelayMs</c> parses (default 5 ms, 0 allowed, negative clamps to 0).</summary>
|
|
[Theory]
|
|
[InlineData(null, 5)]
|
|
[InlineData("0", 0)]
|
|
[InlineData("25", 25)]
|
|
[InlineData("-3", 0)]
|
|
public void Config_browsePageDelayMs_parses(string? json, int expectedMs)
|
|
{
|
|
var config = $$"""{{{(json is null ? "" : $"\"browsePageDelayMs\":{json},")}}"devices":[{{Device}}]}""";
|
|
|
|
AbCipDriverFactoryExtensions.ParseOptions("ab1", config).BrowsePageDelay
|
|
.ShouldBe(TimeSpan.FromMilliseconds(expectedMs));
|
|
}
|
|
}
|