using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests; /// /// Covers : the shared BuildOptions projection /// (driver-options mapping the four commands depend on), the RejectStructure /// guard, the Timeout override behaviour, and TimeoutMs validation. /// [Trait("Category", "Unit")] public sealed class AbCipCommandBaseTests { /// /// Local subclass that surfaces the protected helpers + properties under test. /// [CliFx.Attributes.Command("test")] private sealed class TestableCommand : AbCipCommandBase { public AbCipDriverOptions InvokeBuildOptions(IReadOnlyList tags) => BuildOptions(tags); public string InvokeDriverInstanceId => DriverInstanceId; public override ValueTask ExecuteAsync(CliFx.Infrastructure.IConsole console) => ValueTask.CompletedTask; } private static AbCipTagDefinition SampleTag(string name = "Motor01") => new( Name: name, DeviceHostAddress: "ab://10.0.0.5/1,0", TagPath: "Motor01", DataType: AbCipDataType.DInt, Writable: false); [Fact] public void BuildOptions_disables_probe_so_cli_does_not_race_operator_reads() { var cmd = new TestableCommand { Gateway = "ab://10.0.0.5/1,0", Family = AbCipPlcFamily.ControlLogix, TimeoutMs = 5000, }; var options = cmd.InvokeBuildOptions([SampleTag()]); options.Probe.Enabled.ShouldBeFalse(); } [Fact] public void BuildOptions_disables_controller_browse() { var cmd = new TestableCommand { Gateway = "ab://10.0.0.5/1,0", Family = AbCipPlcFamily.ControlLogix, TimeoutMs = 5000, }; var options = cmd.InvokeBuildOptions([SampleTag()]); options.EnableControllerBrowse.ShouldBeFalse(); } [Fact] public void BuildOptions_disables_alarm_projection() { var cmd = new TestableCommand { Gateway = "ab://10.0.0.5/1,0", Family = AbCipPlcFamily.ControlLogix, TimeoutMs = 5000, }; var options = cmd.InvokeBuildOptions([SampleTag()]); options.EnableAlarmProjection.ShouldBeFalse(); } [Fact] public void BuildOptions_produces_one_device_with_gateway_family_and_derived_name() { var cmd = new TestableCommand { Gateway = "ab://10.0.0.5/1,0", Family = AbCipPlcFamily.CompactLogix, TimeoutMs = 5000, }; var options = cmd.InvokeBuildOptions([SampleTag()]); options.Devices.Count.ShouldBe(1); var device = options.Devices[0]; device.HostAddress.ShouldBe("ab://10.0.0.5/1,0"); device.PlcFamily.ShouldBe(AbCipPlcFamily.CompactLogix); device.DeviceName.ShouldBe("cli-CompactLogix"); } [Fact] public void BuildOptions_passes_supplied_tag_list_verbatim() { var tags = new[] { SampleTag("t1"), SampleTag("t2") }; var cmd = new TestableCommand { Gateway = "ab://10.0.0.5/1,0", Family = AbCipPlcFamily.ControlLogix, TimeoutMs = 5000, }; var options = cmd.InvokeBuildOptions(tags); options.Tags.Count.ShouldBe(2); options.Tags[0].Name.ShouldBe("t1"); options.Tags[1].Name.ShouldBe("t2"); } [Fact] public void BuildOptions_carries_TimeoutMs_through_to_Timeout() { var cmd = new TestableCommand { Gateway = "ab://10.0.0.5/1,0", Family = AbCipPlcFamily.ControlLogix, TimeoutMs = 7500, }; var options = cmd.InvokeBuildOptions([SampleTag()]); options.Timeout.ShouldBe(TimeSpan.FromMilliseconds(7500)); } [Fact] public void DriverInstanceId_embeds_gateway_for_log_disambiguation() { var cmd = new TestableCommand { Gateway = "ab://10.0.0.5/1,0", Family = AbCipPlcFamily.ControlLogix, }; cmd.InvokeDriverInstanceId.ShouldBe("abcip-cli-ab://10.0.0.5/1,0"); } [Fact] public void Timeout_setter_is_inert_and_does_not_silently_swallow_assignments() { // Driver.AbCip.Cli-006 — the empty init body would silently discard an // object-initializer assignment, hiding a "driven by TimeoutMs" misuse. The fix // makes it fail-fast with NotSupportedException so the contract is explicit. Should.Throw(() => new TestableCommand { Gateway = "ab://10.0.0.5/1,0", Timeout = TimeSpan.FromSeconds(99), }); } [Theory] [InlineData(0)] [InlineData(-1)] public void Timeout_get_throws_CommandException_when_TimeoutMs_is_non_positive(int badMs) { // Driver.AbCip.Cli-004 — TimeoutMs must be > 0. Validation is exposed via the // Timeout getter so any command path that touches Timeout sees the same guard. var cmd = new TestableCommand { Gateway = "ab://10.0.0.5/1,0", TimeoutMs = badMs, }; var ex = Should.Throw(() => _ = cmd.Timeout); ex.Message.ShouldContain("--timeout-ms"); } [Fact] public void RejectStructure_throws_for_Structure_DataType() { var ex = Should.Throw( () => CallRejectStructure(AbCipDataType.Structure)); ex.Message.ShouldContain("Structure"); } [Theory] [InlineData(AbCipDataType.DInt)] [InlineData(AbCipDataType.Bool)] [InlineData(AbCipDataType.Real)] public void RejectStructure_passes_for_atomic_types(AbCipDataType type) { // No throw — atomic types are allowed. Should.NotThrow(() => CallRejectStructure(type)); } // The static helper is protected; reflect to it once so the test stays at AbCipCommandBase. private static void CallRejectStructure(AbCipDataType type) { var method = typeof(AbCipCommandBase).GetMethod( "RejectStructure", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static) ?? throw new InvalidOperationException("RejectStructure not found"); try { method.Invoke(null, [type]); } catch (System.Reflection.TargetInvocationException tie) when (tie.InnerException is not null) { throw tie.InnerException; } } }