using System.Reflection; using CliFx.Attributes; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Commands; namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests; /// /// Covers / wiring: /// the canonical gateway string, the driver instance id, the BuildOptions field projection /// (Driver.TwinCAT.Cli-006), and the up-front range validation guards /// (Driver.TwinCAT.Cli-001). /// [Trait("Category", "Unit")] public sealed class TwinCATCommandBaseTests { [Fact] public void Gateway_uses_canonical_ads_scheme_with_port() { var cmd = new ProbeCommand { AmsNetId = "192.168.1.40.1.1", AmsPort = 851, SymbolPath = "MAIN.bRunning", }; cmd.GatewayForTest.ShouldBe("ads://192.168.1.40.1.1:851"); } [Fact] public void Gateway_round_trips_through_TwinCATAmsAddress_TryParse() { // Driver.TwinCAT.Cli-006: a regression in the Gateway string breaks every command // because the driver's TwinCATAmsAddress.TryParse refuses anything not shaped // ads://{netId}:{port}. var cmd = new ProbeCommand { AmsNetId = "5.23.91.23.1.1", AmsPort = 852, SymbolPath = "MAIN.x", }; var parsed = TwinCAT.TwinCATAmsAddress.TryParse(cmd.GatewayForTest); parsed.ShouldNotBeNull(); parsed!.NetId.ShouldBe("5.23.91.23.1.1"); parsed.Port.ShouldBe(852); } [Fact] public void DriverInstanceId_includes_ams_target() { var cmd = new ProbeCommand { AmsNetId = "127.0.0.1.1.1", AmsPort = 851, SymbolPath = "MAIN.x", }; cmd.DriverInstanceIdForTest.ShouldBe("twincat-cli-127.0.0.1.1.1:851"); } [Fact] public void Timeout_is_projection_of_TimeoutMs_and_init_is_noop() { var cmd = new ProbeCommand { AmsNetId = "127.0.0.1.1.1", TimeoutMs = 7777, SymbolPath = "MAIN.x", }; cmd.Timeout.ShouldBe(TimeSpan.FromMilliseconds(7777)); } [Fact] public void BuildOptions_wires_device_tags_timeout_and_disables_probe() { // Driver.TwinCAT.Cli-006: cover the property-by-property wiring that the four runtime // commands depend on. Probe must be disabled (CLI is one-shot — the probe loop would // race the operator's own reads) and controller-browse must stay off. var cmd = new ProbeCommand { AmsNetId = "10.0.0.1.1.1", AmsPort = 851, TimeoutMs = 4321, SymbolPath = "MAIN.x", }; var tag = new TwinCAT.TwinCATTagDefinition( Name: "n1", DeviceHostAddress: cmd.GatewayForTest, SymbolPath: "MAIN.x", DataType: TwinCAT.TwinCATDataType.DInt, Writable: false); var options = cmd.BuildOptionsForTest([tag]); options.Devices.Count.ShouldBe(1); options.Devices[0].HostAddress.ShouldBe("ads://10.0.0.1.1.1:851"); options.Devices[0].DeviceName.ShouldBe("cli-10.0.0.1.1.1:851"); options.Tags.ShouldBe([tag]); options.Timeout.ShouldBe(TimeSpan.FromMilliseconds(4321)); options.Probe.Enabled.ShouldBeFalse(); options.EnableControllerBrowse.ShouldBeFalse(); // Default UseNativeNotifications = true (no --poll-only). options.UseNativeNotifications.ShouldBeTrue(); } [Fact] public void BuildOptions_PollOnly_flips_UseNativeNotifications_off() { var cmd = new ProbeCommand { AmsNetId = "10.0.0.1.1.1", SymbolPath = "MAIN.x", PollOnly = true, }; cmd.BuildOptionsForTest([]).UseNativeNotifications.ShouldBeFalse(); } // ---- Driver.TwinCAT.Cli-001 (range validation) ---- [Fact] public void Validate_rejects_zero_timeout() { var cmd = new ProbeCommand { AmsNetId = "127.0.0.1.1.1", SymbolPath = "MAIN.x", TimeoutMs = 0, }; var ex = Should.Throw(() => cmd.ValidateForTest()); ex.Message.ShouldContain("--timeout-ms"); } [Fact] public void Validate_rejects_negative_timeout() { var cmd = new ProbeCommand { AmsNetId = "127.0.0.1.1.1", SymbolPath = "MAIN.x", TimeoutMs = -1, }; Should.Throw(() => cmd.ValidateForTest()); } [Theory] [InlineData(0)] [InlineData(-1)] [InlineData(65536)] [InlineData(100000)] public void Validate_rejects_out_of_range_ams_port(int port) { var cmd = new ProbeCommand { AmsNetId = "127.0.0.1.1.1", SymbolPath = "MAIN.x", AmsPort = port, }; var ex = Should.Throw(() => cmd.ValidateForTest()); ex.Message.ShouldContain("--ams-port"); } [Theory] [InlineData(1)] [InlineData(801)] [InlineData(851)] [InlineData(65535)] public void Validate_accepts_in_range_ams_port(int port) { var cmd = new ProbeCommand { AmsNetId = "127.0.0.1.1.1", SymbolPath = "MAIN.x", AmsPort = port, }; Should.NotThrow(() => cmd.ValidateForTest()); } [Fact] public void SubscribeCommand_validate_rejects_zero_interval() { var cmd = new SubscribeCommand { AmsNetId = "127.0.0.1.1.1", SymbolPath = "MAIN.x", IntervalMs = 0, }; var ex = Should.Throw(() => cmd.ValidateForTest()); ex.Message.ShouldContain("--interval-ms"); } [Fact] public void SubscribeCommand_validate_rejects_negative_interval() { var cmd = new SubscribeCommand { AmsNetId = "127.0.0.1.1.1", SymbolPath = "MAIN.x", IntervalMs = -100, }; Should.Throw(() => cmd.ValidateForTest()); } // ---- Driver.TwinCAT.Cli-004 (PollOnly off BrowseCommand surface) ---- [Fact] public void BrowseCommand_does_not_expose_poll_only_flag() { // Driver.TwinCAT.Cli-004: the flag has no observable effect on browse — surfacing it // misleads users. After the refactor, PollOnly lives on an intermediate base shared // only by the commands that actually consume native ADS notifications. var props = typeof(BrowseCommand) .GetProperties(BindingFlags.Public | BindingFlags.Instance); props.ShouldNotContain(p => p.Name == "PollOnly"); } [Fact] public void ProbeCommand_still_exposes_poll_only_flag() { // Probe / Read / Write / Subscribe all build TwinCATDriverOptions and so still take // the --poll-only toggle. var props = typeof(ProbeCommand) .GetProperties(BindingFlags.Public | BindingFlags.Instance); props.ShouldContain(p => p.Name == "PollOnly"); } // ---- Driver.TwinCAT.Cli-005 (probe --type short alias) ---- [Fact] public void ProbeCommand_type_option_carries_short_alias_t() { // Driver.TwinCAT.Cli-005: --type on read/write/subscribe takes the -t short alias; // probe must match so muscle memory works the same way across all four verbs. var dataTypeProp = typeof(ProbeCommand).GetProperty("DataType"); dataTypeProp.ShouldNotBeNull(); var attr = dataTypeProp!.GetCustomAttribute(); attr.ShouldNotBeNull(); attr!.ShortName.ShouldBe('t'); } }