feat(ab): native managed transport is the default for AbCip and AbLegacy; libplctag remains as fallback
Task 22 (spec section 8 P5), with the controller rulings that exclude PLC-5 and LogixPccc. - Transport is nullable on both option records; null (key absent) means "use the default" and is never serialized. Factory DTO parse: absent -> null; a bad value still fails via ParseEnum. - One resolver per driver (AbCipTransportResolver / AbLegacyTransportResolver), used by the driver constructor, the Test Connect probe and (through the driver) the CLI. AbCip: null -> Native. AbLegacy: null -> Native unless any device is Plc5 or LogixPccc, then the whole driver resolves to Libplctag. Explicit values always win; explicit Native with a Plc5/LogixPccc device logs one Warning at init. - Init log shows configured vs effective, e.g. transport=Native (default). - AdminUI forms: "Default (...)" / "Native (managed)" / "libplctag (fallback)". Default omits the key on save (reverses Task 19's explicit re-emit, which would have pinned every edited driver). - CLIs: --transport default|native|libplctag, default "default" via the resolver, so --plc-type Plc5 with no --transport runs libplctag. - Tests pinned to Libplctag where a default-path runtime was created: found by instrumenting the native factories with the current xUnit test name (one unit test, AbLegacyReinitConfigAdoption; plus the two fixture-gated AbCip probe E2E tests whose exact messages were proven on libplctag). TransportMatrix and the differential harness already pass explicit transports. - Switch/resolver/CLI/form tests are falsified by mutating the resolver (default -> Libplctag, exclusion removed, AbLegacy default -> Libplctag): each mutation turns them red. - Replaces the inline role-name literal in AbCipDriverFactoryExtensions' ParseEnum call with a named constant; RoleAuthorityGuardTests was already red on the base commit for that line. - Docs: AbCip.md / AbLegacy.md default + release note; CLAUDE.md AB section. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+15
-4
@@ -14,26 +14,34 @@ public sealed class AbCipTransportOptionTests
|
||||
private sealed class CapturingCommand : AbCipCommandBase
|
||||
{
|
||||
public static AbCipTransport? Captured;
|
||||
public static bool Ran;
|
||||
|
||||
public override ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
Ran = true;
|
||||
Captured = BuildOptions([]).Transport;
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies the default is libplctag when the flag is absent.</summary>
|
||||
/// <summary>
|
||||
/// With no flag the CLI leaves the option null ("use the default"), and the driver's resolver makes
|
||||
/// that Native — the same path an absent <c>transport</c> key takes in a deployed config.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Transport_defaults_to_libplctag()
|
||||
public async Task Transport_defaults_to_the_resolver_default_native()
|
||||
{
|
||||
CapturingCommand.Captured = null;
|
||||
CapturingCommand.Ran = false;
|
||||
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"]);
|
||||
|
||||
exit.ShouldBe(0, console.ReadErrorString());
|
||||
CapturingCommand.Captured.ShouldBe(AbCipTransport.Libplctag);
|
||||
CapturingCommand.Ran.ShouldBeTrue();
|
||||
CapturingCommand.Captured.ShouldBeNull();
|
||||
AbCipTransportResolver.Resolve(CapturingCommand.Captured).ShouldBe(AbCipTransport.Native);
|
||||
}
|
||||
|
||||
/// <summary>Verifies CliFx parses <c>--transport</c> (case-insensitive) into the options.</summary>
|
||||
@@ -41,8 +49,10 @@ public sealed class AbCipTransportOptionTests
|
||||
[InlineData("native", AbCipTransport.Native)]
|
||||
[InlineData("Native", AbCipTransport.Native)]
|
||||
[InlineData("libplctag", AbCipTransport.Libplctag)]
|
||||
public async Task Transport_flag_is_parsed_by_the_cli(string value, AbCipTransport expected)
|
||||
[InlineData("default", null)]
|
||||
public async Task Transport_flag_is_parsed_by_the_cli(string value, AbCipTransport? expected)
|
||||
{
|
||||
CapturingCommand.Ran = false;
|
||||
CapturingCommand.Captured = null;
|
||||
using var console = new FakeInMemoryConsole();
|
||||
var app = new CliApplicationBuilder().AddCommand(typeof(CapturingCommand)).UseConsole(console).Build();
|
||||
@@ -50,6 +60,7 @@ public sealed class AbCipTransportOptionTests
|
||||
var exit = await app.RunAsync(["capture-transport", "--gateway", "ab://1.2.3.4/1,0", "--transport", value]);
|
||||
|
||||
exit.ShouldBe(0, console.ReadErrorString());
|
||||
CapturingCommand.Ran.ShouldBeTrue();
|
||||
CapturingCommand.Captured.ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
|
||||
+50
-13
@@ -161,42 +161,79 @@ public sealed class BuildOptionsTests
|
||||
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()
|
||||
/// <summary>
|
||||
/// With no <c>--transport</c> the CLI leaves the option null and the driver's resolver decides: Native
|
||||
/// for an SLC 500, libplctag for <c>--plc-type Plc5</c> / LogixPccc (Task 22 exclusion). An explicit
|
||||
/// choice is forwarded verbatim and wins.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(AbLegacyPlcFamily.Slc500, AbLegacyTransport.Native)]
|
||||
[InlineData(AbLegacyPlcFamily.MicroLogix, AbLegacyTransport.Native)]
|
||||
[InlineData(AbLegacyPlcFamily.Plc5, AbLegacyTransport.Libplctag)]
|
||||
[InlineData(AbLegacyPlcFamily.LogixPccc, AbLegacyTransport.Libplctag)]
|
||||
public void BuildOptions_default_transport_goes_through_the_resolver(AbLegacyPlcFamily family, AbLegacyTransport expected)
|
||||
{
|
||||
var def = new TestCommand { Gateway = "ab://192.168.1.20/1,0" };
|
||||
def.Build([]).Transport.ShouldBe(AbLegacyTransport.Libplctag);
|
||||
var options = new TestCommand { Gateway = "ab://192.168.1.20/1,0", PlcType = family }.Build([]);
|
||||
options.Transport.ShouldBeNull();
|
||||
AbLegacyTransportResolver.Resolve(options).ShouldBe(expected);
|
||||
|
||||
var native = new TestCommand { Gateway = "ab://192.168.1.20/1,0", Transport = AbLegacyTransport.Native };
|
||||
native.Build([]).Transport.ShouldBe(AbLegacyTransport.Native);
|
||||
var native = new TestCommand
|
||||
{
|
||||
Gateway = "ab://192.168.1.20/1,0", PlcType = family, Transport = AbLegacyCliTransport.Native,
|
||||
}.Build([]);
|
||||
native.Transport.ShouldBe(AbLegacyTransport.Native);
|
||||
AbLegacyTransportResolver.Resolve(native).ShouldBe(AbLegacyTransport.Native);
|
||||
}
|
||||
|
||||
/// <summary>Verifies CliFx really parses <c>--transport native</c> (case-insensitive) into the property.</summary>
|
||||
/// <summary>
|
||||
/// The plan's CLI case end to end through CliFx: <c>--plc-type plc5</c> with no <c>--transport</c>
|
||||
/// resolves to libplctag; the same invocation for an SLC resolves Native (falsification control).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("plc5", AbLegacyTransport.Libplctag)]
|
||||
[InlineData("logixpccc", AbLegacyTransport.Libplctag)]
|
||||
[InlineData("slc500", AbLegacyTransport.Native)]
|
||||
public async Task Plc_type_without_transport_flag_resolves_through_the_resolver(string plcType, AbLegacyTransport expected)
|
||||
{
|
||||
CapturingCommand.Options = 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", "--plc-type", plcType]);
|
||||
|
||||
exit.ShouldBe(0, console.ReadErrorString());
|
||||
CapturingCommand.Options.ShouldNotBeNull();
|
||||
CapturingCommand.Options.Transport.ShouldBeNull();
|
||||
AbLegacyTransportResolver.Resolve(CapturingCommand.Options).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>Verifies CliFx really parses <c>--transport</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)
|
||||
[InlineData("default", null)]
|
||||
public async Task Transport_flag_is_parsed_by_the_cli(string value, AbLegacyTransport? expected)
|
||||
{
|
||||
CapturingCommand.Captured = null;
|
||||
CapturingCommand.Options = 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);
|
||||
CapturingCommand.Options.ShouldNotBeNull();
|
||||
CapturingCommand.Options.Transport.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Command("capture-transport")]
|
||||
private sealed class CapturingCommand : AbLegacyCommandBase
|
||||
{
|
||||
public static AbLegacyTransport? Captured;
|
||||
public static AbLegacyDriverOptions? Options;
|
||||
|
||||
public override System.Threading.Tasks.ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
Captured = BuildOptions([]).Transport;
|
||||
Options = BuildOptions([]);
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,13 +12,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipTransportInitLogTests
|
||||
{
|
||||
private static string Config(string transport) =>
|
||||
$$"""{"transport":"{{transport}}","probe":{"enabled":false},"devices":[{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"ControlLogix"}]}""";
|
||||
private static string Config(string? transport) =>
|
||||
$$"""{{{(transport is null ? "" : $"\"transport\":\"{transport}\",")}}"probe":{"enabled":false},"devices":[{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"ControlLogix"}]}""";
|
||||
|
||||
/// <summary>The init line names the configured-vs-effective transport (Task 22).</summary>
|
||||
[Theory]
|
||||
[InlineData("Native", AbCipTransport.Native)]
|
||||
[InlineData("Libplctag", AbCipTransport.Libplctag)]
|
||||
public async Task Init_logs_the_transport_through_the_registered_logger_factory(string json, AbCipTransport expected)
|
||||
[InlineData("Native", "Native (configured)")]
|
||||
[InlineData("Libplctag", "Libplctag (configured)")]
|
||||
[InlineData(null, "Native (default)")]
|
||||
public async Task Init_logs_the_transport_through_the_registered_logger_factory(string? json, string expected)
|
||||
{
|
||||
var captured = new List<string>();
|
||||
using var factory = new CapturingLoggerFactory(captured);
|
||||
|
||||
@@ -14,17 +14,23 @@ public sealed class AbCipTransportSwitchTests
|
||||
private static string Config(string? transportJson) =>
|
||||
$$"""{{{(transportJson is null ? "" : $"\"transport\":{transportJson},")}}"devices":[{{Device}}]}""";
|
||||
|
||||
/// <summary>Transport values select the factory trio; absent defaults to libplctag.</summary>
|
||||
/// <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.Libplctag)]
|
||||
[InlineData(null, AbCipTransport.Native)]
|
||||
public void Config_transport_selects_the_factory(string? json, AbCipTransport expected)
|
||||
{
|
||||
AbCipDriverFactoryExtensions.ParseOptions("ab1", Config(json)).Transport.ShouldBe(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
|
||||
@@ -119,6 +125,41 @@ public sealed class AbCipTransportSwitchTests
|
||||
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()
|
||||
|
||||
+4
-1
@@ -39,9 +39,12 @@ public sealed class AbLegacyReinitConfigAdoptionTests
|
||||
[Fact]
|
||||
public async Task Reinitialize_with_an_empty_document_keeps_the_constructor_options()
|
||||
{
|
||||
// Pinned to libplctag (Task 22): this init starts the default probe loop against 10.0.0.1 with no
|
||||
// injected factory, and was written and proven on libplctag. Pinning keeps it off the process-wide
|
||||
// native DeviceSessionPool rather than silently changing what it exercises.
|
||||
var driver = AbLegacyDriverFactoryExtensions.CreateInstance(
|
||||
"ab1",
|
||||
"""{"devices":[{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"Slc500"}]}""",
|
||||
"""{"transport":"Libplctag","devices":[{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"Slc500"}]}""",
|
||||
loggerFactory: null);
|
||||
|
||||
await Should.NotThrowAsync(() => driver.ReinitializeAsync("{}", CancellationToken.None));
|
||||
|
||||
+42
-13
@@ -12,44 +12,73 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyTransportInitLogTests
|
||||
{
|
||||
private static string Config(string transport) =>
|
||||
$$"""{"transport":"{{transport}}","probe":{"enabled":false},"devices":[{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"Slc500"}]}""";
|
||||
private static string Config(string? transport, string family = "Slc500") =>
|
||||
$$"""{{{(transport is null ? "" : $"\"transport\":\"{transport}\",")}}"probe":{"enabled":false},"devices":[{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"{{family}}"}]}""";
|
||||
|
||||
/// <summary>The init line names the configured-vs-effective transport (Task 22).</summary>
|
||||
[Theory]
|
||||
[InlineData("Native", AbLegacyTransport.Native)]
|
||||
[InlineData("Libplctag", AbLegacyTransport.Libplctag)]
|
||||
public async Task Init_logs_the_transport_through_the_registered_logger_factory(string json, AbLegacyTransport expected)
|
||||
[InlineData("Native", "Slc500", "Native (configured)")]
|
||||
[InlineData("Libplctag", "Slc500", "Libplctag (configured)")]
|
||||
[InlineData(null, "Slc500", "Native (default)")]
|
||||
[InlineData(null, "Plc5", "Libplctag (default; PLC-5/LogixPccc device present)")]
|
||||
public async Task Init_logs_the_transport_through_the_registered_logger_factory(
|
||||
string? json, string family, string expected)
|
||||
{
|
||||
var captured = new List<string>();
|
||||
var captured = await InitAndCaptureAsync(Config(json, family));
|
||||
captured.ShouldContain(m => m.Message.Contains("drv-t19") && m.Message.Contains($"transport={expected}"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An explicit Native against a PLC-5 / LogixPccc device is an allowed operator choice, but logs ONE
|
||||
/// Warning naming the limitation. The defaulted and explicit-libplctag rows are the falsification
|
||||
/// controls: same device families, no warning.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("Native", "Plc5", true)]
|
||||
[InlineData("Native", "LogixPccc", true)]
|
||||
[InlineData(null, "Plc5", false)]
|
||||
[InlineData("Libplctag", "LogixPccc", false)]
|
||||
[InlineData("Native", "Slc500", false)]
|
||||
public async Task Explicit_Native_on_a_limited_family_warns_once(string? json, string family, bool warns)
|
||||
{
|
||||
var captured = await InitAndCaptureAsync(Config(json, family));
|
||||
var warnings = captured.Where(m => m.Level == LogLevel.Warning && m.Message.Contains("native support is limited"))
|
||||
.ToList();
|
||||
warnings.Count.ShouldBe(warns ? 1 : 0);
|
||||
if (warns) warnings[0].Message.ShouldContain(family);
|
||||
}
|
||||
|
||||
private static async Task<List<(LogLevel Level, string Message)>> InitAndCaptureAsync(string config)
|
||||
{
|
||||
var captured = new List<(LogLevel, string)>();
|
||||
using var factory = new CapturingLoggerFactory(captured);
|
||||
var driver = AbLegacyDriverFactoryExtensions.CreateInstance("drv-t19", Config(json), factory);
|
||||
var driver = AbLegacyDriverFactoryExtensions.CreateInstance("drv-t19", config, factory);
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync(Config(json), TestContext.Current.CancellationToken);
|
||||
await driver.InitializeAsync(config, TestContext.Current.CancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
captured.ShouldContain(m => m.Contains("drv-t19") && m.Contains($"transport={expected}"));
|
||||
lock (captured) return [.. captured];
|
||||
}
|
||||
|
||||
private sealed class CapturingLoggerFactory(List<string> sink) : ILoggerFactory
|
||||
private sealed class CapturingLoggerFactory(List<(LogLevel, string)> sink) : ILoggerFactory
|
||||
{
|
||||
public ILogger CreateLogger(string categoryName) => new CapturingLogger(sink);
|
||||
public void AddProvider(ILoggerProvider provider) { }
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
private sealed class CapturingLogger(List<string> sink) : ILogger
|
||||
private sealed class CapturingLogger(List<(LogLevel, string)> sink) : ILogger
|
||||
{
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
lock (sink) sink.Add(formatter(state, exception));
|
||||
lock (sink) sink.Add((logLevel, formatter(state, exception)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+94
-6
@@ -14,23 +14,74 @@ public sealed class AbLegacyTransportSwitchTests
|
||||
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>
|
||||
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 Native for an SLC 500 (Task 22). An explicit Libplctag still wins.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("\"Native\"", AbLegacyTransport.Native)]
|
||||
[InlineData("\"native\"", AbLegacyTransport.Native)]
|
||||
[InlineData("\"Libplctag\"", AbLegacyTransport.Libplctag)]
|
||||
[InlineData(null, AbLegacyTransport.Libplctag)]
|
||||
[InlineData(null, AbLegacyTransport.Native)]
|
||||
public void Config_transport_selects_the_factory(string? json, AbLegacyTransport expected)
|
||||
{
|
||||
AbLegacyDriverFactoryExtensions.ParseOptions("ab1", Config(json)).Transport.ShouldBe(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>
|
||||
/// The exclusion: an absent key resolves the WHOLE driver to libplctag when any device is a PLC-5 or a
|
||||
/// LogixPccc (limited native support); the factory is driver-level, so one such device among SLCs
|
||||
/// is enough. The SLC/MicroLogix-only rows are the falsification control — they must resolve Native.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(AbLegacyTransport.Libplctag, "Plc5")]
|
||||
[InlineData(AbLegacyTransport.Libplctag, "LogixPccc")]
|
||||
[InlineData(AbLegacyTransport.Libplctag, "Slc500", "Plc5")]
|
||||
[InlineData(AbLegacyTransport.Libplctag, "MicroLogix", "LogixPccc")]
|
||||
[InlineData(AbLegacyTransport.Native, "Slc500")]
|
||||
[InlineData(AbLegacyTransport.Native, "MicroLogix")]
|
||||
[InlineData(AbLegacyTransport.Native, "Slc500", "MicroLogix")]
|
||||
public void Absent_transport_excludes_PLC5_and_LogixPccc(AbLegacyTransport expected, params string[] families)
|
||||
{
|
||||
var json = Config(null, families);
|
||||
AbLegacyTransportResolver.Resolve(AbLegacyDriverFactoryExtensions.ParseOptions("ab1", json)).ShouldBe(expected);
|
||||
|
||||
var driver = AbLegacyDriverFactoryExtensions.CreateInstance("ab1", json);
|
||||
driver.EffectiveTransport.ShouldBe(expected);
|
||||
if (expected == AbLegacyTransport.Native)
|
||||
driver.TagFactory.ShouldBeOfType<NativeAbLegacyTagFactory>();
|
||||
else
|
||||
driver.TagFactory.ShouldBeOfType<LibplctagLegacyTagFactory>();
|
||||
}
|
||||
|
||||
/// <summary>An explicit value always wins over the exclusion, in both directions.</summary>
|
||||
[Theory]
|
||||
[InlineData("\"Native\"", "Plc5", AbLegacyTransport.Native)]
|
||||
[InlineData("\"Native\"", "LogixPccc", 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()
|
||||
@@ -81,14 +132,14 @@ public sealed class AbLegacyTransportSwitchTests
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
var accept = Task.Run(async () =>
|
||||
_ = 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"}]}""";
|
||||
@@ -102,7 +153,44 @@ public sealed class AbLegacyTransportSwitchTests
|
||||
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");
|
||||
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 the native path (native failure text), a PLC-5 with no key takes libplctag (no native
|
||||
/// text) — against the same listener, so only the resolution differs.
|
||||
/// </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 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);
|
||||
listener.Stop();
|
||||
|
||||
AbLegacyTransportResolver.Resolve(AbLegacyDriverFactoryExtensions.ParseOptions("p", Cfg("Slc500")))
|
||||
.ShouldBe(AbLegacyTransport.Native);
|
||||
AbLegacyTransportResolver.Resolve(AbLegacyDriverFactoryExtensions.ParseOptions("p", Cfg("Plc5")))
|
||||
.ShouldBe(AbLegacyTransport.Libplctag);
|
||||
slc.Ok.ShouldBeFalse();
|
||||
slc.Message!.ShouldContain("PCCC handshake failed: Connection lost");
|
||||
plc5.Message!.ShouldNotContain("Connection lost");
|
||||
}
|
||||
|
||||
/// <summary>The probe deserialises with its own string-enum converter; a bad transport is a config error.</summary>
|
||||
|
||||
Reference in New Issue
Block a user