using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; /// /// Review 07 H17: Logix allows a .n bit selector on SINT / INT / DINT (and LINT) hosts, and reads of /// Flags.3 on an INT worked — but the bit write's read-modify-write hard-coded a DINT parent, so its /// DecodeValue(DInt) on a 2-byte buffer threw OutOfBounds and the write was reported as a comms fault /// (BadCommunicationError + Degraded). The parent width now comes from the parent read's size. /// [Trait("Category", "Unit")] public sealed class AbCipBitInWordWidthTests { private const string Device = "ab://10.0.0.5/1,0"; private static async Task<(AbCipDriver Driver, FakeAbCipTagFactory Factory)> BuildAsync(int parentSize, object parentValue, string bitPath) { var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Value = parentValue, Size = parentSize }, }; var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], RawTags = AbCipRawTags.From(new AbCipTagDefinition("Bit", Device, bitPath, AbCipDataType.Bool)), Probe = new AbCipProbeOptions { Enabled = false }, }, "abcip-bit", factory); await drv.InitializeAsync("{}", CancellationToken.None); return (drv, factory); } /// SINT / INT / DINT hosts: the bit is set, every other bit is preserved, nothing degrades. /// The parent's wire width. /// The parent's value before the write. /// The bit written true. /// The value the parent must be written back with. [Theory] [InlineData(1, 0x81, 3, 0x89)] // SINT 0x81 (-127): bit 7 set stays set, written as its unsigned byte [InlineData(1, 0x00, 7, 0x80)] // SINT top bit: 128 would overflow an sbyte encode [InlineData(2, 0x8001, 3, 0x8009)] // INT top bit preserved [InlineData(2, 0x0000, 15, 0x8000)] // INT top bit: 32768 would overflow a short encode [InlineData(4, 0x00000001, 3, 0x00000009)] public async Task A_bit_write_uses_the_parent_width(int size, int before, int bit, int after) { var (drv, factory) = await BuildAsync(size, before, $"Motor.Flags.{bit}"); var r = await drv.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None); r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); Convert.ToInt64(factory.Tags["Motor.Flags"].Value).ShouldBe(after); factory.Tags["Motor.Flags"].WriteCount.ShouldBe(1); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } /// A bit past the host's width is refused before anything is written, with no health write. [Fact] public async Task A_bit_past_the_host_width_is_BadOutOfRange_and_nothing_is_written() { var (drv, factory) = await BuildAsync(2, 0, "Motor.Flags.20"); var r = await drv.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None); r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange); factory.Tags["Motor.Flags"].WriteCount.ShouldBe(0); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } /// A host whose width is no integer's is a type mismatch, refused before writing, no health write. [Fact] public async Task A_host_of_no_integer_width_is_BadTypeMismatch() { var (drv, factory) = await BuildAsync(3, 0, "Motor.Flags.1"); var r = await drv.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None); r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch); factory.Tags["Motor.Flags"].WriteCount.ShouldBe(0); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } /// /// End to end over the REAL native runtime against a fake CIP target whose Flags is an INT: the write /// goes out as ONE INT (type 0xC3, two data bytes) with the bit set and the other bits preserved. /// [Fact] public async Task A_native_bit_write_on_an_INT_host_writes_an_INT() { await using var target = new FakeCipTarget(); // Read Tag Fragmented (0x52) → INT (0x00C3) 0x8001; Write Tag (0x4D) → success. target.ConnectedHandler = req => Task.FromResult(req[0] switch { 0x52 => [0xD2, 0x00, 0x00, 0x00, 0xC3, 0x00, 0x01, 0x80], 0x4D => [0xCD, 0x00, 0x00, 0x00], _ => target.ServiceReply(req), }); var host = $"ab://127.0.0.1:{target.Port}/1,0"; var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(host, AbCipPlcFamily.ControlLogix)], RawTags = AbCipRawTags.From(new AbCipTagDefinition("Bit", host, "Flags.3", AbCipDataType.Bool)), Probe = new AbCipProbeOptions { Enabled = false }, Timeout = TimeSpan.FromSeconds(5), }, "abcip-bit-native", new NativeAbCipTagFactory(new DeviceSessionPool())); await drv.InitializeAsync("{}", CancellationToken.None); try { var r = await drv.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None); r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); var write = target.ConnectedCips.Single(c => c[0] == 0x4D); var typeAt = 2 + write[1] * 2; write[typeAt].ShouldBe((byte)0xC3); // INT, not DINT write.Length.ShouldBe(typeAt + 2 + 2 + 2); // type, element count, ONE 2-byte element write[^2].ShouldBe((byte)0x09); // 0x8001 | bit 3 write[^1].ShouldBe((byte)0x80); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } finally { await drv.DisposeAsync(); } } }