using System.Net; using System.Net.Sockets; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; /// The per-driver Transport switch: config parsing, driver factory selection and the probe. [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}]}}"; } /// /// 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. /// [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(); else driver.TagFactory.ShouldBeOfType(); } /// /// 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. /// [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(); } /// The init-log rendering names the default as such, and an explicit choice as configured. [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)"); } /// An explicit value always wins, for every family. [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); } /// A bad value fails the way an existing bad enum field (PlcFamily) does. [Fact] public void Bad_transport_fails_like_a_bad_PlcFamily() { var bad = Should.Throw( () => AbLegacyDriverFactoryExtensions.ParseOptions("ab1", Config("\"Bogus\""))); bad.Message.ShouldContain("unknown Transport 'Bogus'"); var existing = Should.Throw(() => 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'"); } /// /// An undefined integer string is rejected, not silently run on a switch's default arm (Task 22 review /// r1 M2): Enum.TryParse alone accepts "7". A defined numeric ("1") still parses, as before. /// [Fact] public void An_undefined_numeric_transport_is_rejected() { Should.Throw( () => AbLegacyDriverFactoryExtensions.ParseOptions("ab1", Config("\"7\""))) .Message.ShouldContain("unknown Transport '7'"); AbLegacyDriverFactoryExtensions.ParseOptions("ab1", Config("\"Native\"")).Transport.ShouldBe(AbLegacyTransport.Native); } /// An explicitly injected factory wins over the option. [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); } /// /// Reinit does NOT swap transports (by design): the factory is constructor-held, so a changed /// transport in the reinit JSON is inert. A deploy recreates the instance instead (#516). /// [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(); } /// /// 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. /// [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"); } /// /// 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. /// [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"); } /// The probe deserialises with its own string-enum converter; a bad transport is a config error. [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"); } }