using System; using System.Collections.Generic; using System.Threading.Tasks; using CliFx; using CliFx.Attributes; using CliFx.Infrastructure; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests; /// /// Locks in : probe disabled, /// device shape populated from --gateway + --plc-type, tag list /// forwarded verbatim, and timeout propagated from --timeout-ms. A /// regression here silently changes every AbLegacy CLI command's behaviour, so /// covering it explicitly closes the test gap called out by finding /// Driver.AbLegacy.Cli-007. /// [Trait("Category", "Unit")] public sealed class BuildOptionsTests { // Concrete subclass needed because AbLegacyCommandBase is abstract. Exposes the // protected BuildOptions via a public surface for the test. // [Command] satisfies CliFx's analyzer (ICommand subtypes must be annotated); // we never run it through CliFx, only invoke Build() directly. [Command("test-build-options")] private sealed class TestCommand : AbLegacyCommandBase { /// Builds driver options from the command's configuration and the provided tags. /// The tag definitions to include in the options. /// Configured driver options. public AbLegacyDriverOptions Build(IReadOnlyList tags) => BuildOptions(tags); /// Not used; this test command is for BuildOptions inspection only. /// Not used. /// Not used. /// Always thrown; this method should not be called. public override System.Threading.Tasks.ValueTask ExecuteAsync(IConsole console) => throw new NotSupportedException("TestCommand is for BuildOptions inspection only."); } private static readonly IReadOnlyList SampleTags = [ new AbLegacyTagDefinition( Name: "N7:0:Int", DeviceHostAddress: "ab://192.168.1.20/1,0", Address: "N7:0", DataType: AbLegacyDataType.Int, Writable: false), new AbLegacyTagDefinition( Name: "F8:0:Float", DeviceHostAddress: "ab://192.168.1.20/1,0", Address: "F8:0", DataType: AbLegacyDataType.Float, Writable: true), ]; /// Verifies that probe is disabled for CLI one-shot runs. [Fact] public void BuildOptions_disables_probe_for_cli_oneshot_runs() { var cmd = new TestCommand { Gateway = "ab://192.168.1.20/1,0", PlcType = AbLegacyPlcFamily.Slc500, TimeoutMs = 5000, }; var options = cmd.Build(SampleTags); options.Probe.ShouldNotBeNull(); options.Probe.Enabled.ShouldBeFalse( "CLI commands are one-shot; the background probe loop is unwanted overhead."); } /// Verifies that device is populated from gateway and plc-type options. [Fact] public void BuildOptions_populates_single_device_from_gateway_and_plc_type() { var cmd = new TestCommand { Gateway = "ab://10.0.0.5/1,0", PlcType = AbLegacyPlcFamily.MicroLogix, TimeoutMs = 5000, }; var options = cmd.Build(SampleTags); options.Devices.Count.ShouldBe(1); options.Devices[0].HostAddress.ShouldBe("ab://10.0.0.5/1,0"); options.Devices[0].PlcFamily.ShouldBe(AbLegacyPlcFamily.MicroLogix); options.Devices[0].DeviceName.ShouldBe("cli-MicroLogix"); } /// /// Verifies that the typed tag list is delivered as v3 records: RawPath = /// the def's Name, DeviceName = the def's DeviceHostAddress, and the TagConfig blob round-trips the /// def's address / dataType / writable through the real mapper. /// [Fact] public void BuildOptions_delivers_tags_as_rawtag_entries() { var cmd = new TestCommand { Gateway = "ab://192.168.1.20/1,0", PlcType = AbLegacyPlcFamily.Slc500, TimeoutMs = 5000, }; var options = cmd.Build(SampleTags); options.RawTags.Count.ShouldBe(SampleTags.Count); for (var i = 0; i < SampleTags.Count; i++) { var def = SampleTags[i]; var entry = options.RawTags[i]; entry.RawPath.ShouldBe(def.Name); entry.DeviceName.ShouldBe(def.DeviceHostAddress); AbLegacyTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var round).ShouldBeTrue(); round.Address.ShouldBe(def.Address); round.DataType.ShouldBe(def.DataType); round.Writable.ShouldBe(def.Writable); } } /// Verifies that timeout-ms option is propagated to the driver options. [Fact] public void BuildOptions_propagates_timeout_ms() { var cmd = new TestCommand { Gateway = "ab://192.168.1.20/1,0", PlcType = AbLegacyPlcFamily.Slc500, TimeoutMs = 7500, }; var options = cmd.Build(SampleTags); options.Timeout.ShouldBe(TimeSpan.FromMilliseconds(7500)); } /// Verifies that empty tag list yields an empty tags collection in options. [Fact] public void BuildOptions_with_empty_tag_list_yields_empty_tags_collection() { var cmd = new TestCommand { Gateway = "ab://192.168.1.20/1,0", PlcType = AbLegacyPlcFamily.Plc5, TimeoutMs = 5000, }; var options = cmd.Build([]); options.RawTags.ShouldBeEmpty(); options.Devices.Count.ShouldBe(1); options.Devices[0].PlcFamily.ShouldBe(AbLegacyPlcFamily.Plc5); } /// /// With no --transport the CLI leaves the option null and the driver's resolver decides: libplctag /// for every family (final review I3; Native is opt-in). An explicit choice is forwarded verbatim and wins. /// [Theory] [InlineData(AbLegacyPlcFamily.Slc500, AbLegacyTransport.Libplctag)] [InlineData(AbLegacyPlcFamily.MicroLogix, AbLegacyTransport.Libplctag)] [InlineData(AbLegacyPlcFamily.Plc5, AbLegacyTransport.Libplctag)] [InlineData(AbLegacyPlcFamily.LogixPccc, AbLegacyTransport.Libplctag)] public void BuildOptions_default_transport_goes_through_the_resolver(AbLegacyPlcFamily family, AbLegacyTransport expected) { 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", PlcType = family, Transport = AbLegacyCliTransport.Native, }.Build([]); native.Transport.ShouldBe(AbLegacyTransport.Native); AbLegacyTransportResolver.Resolve(native).ShouldBe(AbLegacyTransport.Native); } /// /// End to end through CliFx: no --transport resolves to libplctag for every --plc-type /// (final review I3). The slc500 / micrologix rows are the falsification rows — they resolved Native /// before the revert. /// [Theory] [InlineData("plc5", AbLegacyTransport.Libplctag)] [InlineData("logixpccc", AbLegacyTransport.Libplctag)] [InlineData("slc500", AbLegacyTransport.Libplctag)] [InlineData("micrologix", AbLegacyTransport.Libplctag)] 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); } /// Verifies CliFx really parses --transport (case-insensitive) into the property. [Theory] [InlineData("native", AbLegacyTransport.Native)] [InlineData("Native", AbLegacyTransport.Native)] [InlineData("libplctag", AbLegacyTransport.Libplctag)] [InlineData("default", null)] public async Task Transport_flag_is_parsed_by_the_cli(string value, 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", "--transport", value]); exit.ShouldBe(0, console.ReadErrorString()); CapturingCommand.Options.ShouldNotBeNull(); CapturingCommand.Options.Transport.ShouldBe(expected); } [Command("capture-transport")] private sealed class CapturingCommand : AbLegacyCommandBase { public static AbLegacyDriverOptions? Options; public override System.Threading.Tasks.ValueTask ExecuteAsync(IConsole console) { Options = BuildOptions([]); return default; } } }