using Shouldly; using TwinCAT.TypeSystem; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests; /// /// Pins — how a bit-indexed BOOL tag (.ADSSTATE_RUN.0) is served by a /// native notification. ADS has no symbol for WORD.N: live on the TwinCAT 2.11 Elma cleaner Z33856 /// (2026-10-07) registering the notification on .ADSSTATE_RUN.0 failed with /// DeviceSymbolNotFound — and that registration error tore down the WHOLE subscribe call. The /// notification now registers the PARENT word as raw bytes (its resolved width) and extracts the bit. /// [Trait("Category", "Unit")] public sealed class TwinCATBitAccessTests { /// The parent path is the bit path without its trailing .N; a non-bit path is unchanged. [Fact] public void ParentPath_strips_the_trailing_bit_index() { TwinCATBitAccess.ParentPath(".ADSSTATE_RUN.0").ShouldBe(".ADSSTATE_RUN"); TwinCATBitAccess.ParentPath("MAIN.Status.Flags.15").ShouldBe("MAIN.Status.Flags"); TwinCATBitAccess.ParentPath("MAIN.Flags").ShouldBe("MAIN.Flags"); } /// Bits are numbered little-endian across the parent's bytes (bit 9 = byte 1, bit 1). [Fact] public void ReadBit_numbers_bits_little_endian_across_the_parent_bytes() { byte[] word = [0b0000_0101, 0b0000_0010]; // .ADSSTATE_RUN = 5 → bits 0 and 2; plus bit 9 TwinCATBitAccess.ReadBit(word, 0).ShouldBeTrue(); TwinCATBitAccess.ReadBit(word, 1).ShouldBeFalse(); TwinCATBitAccess.ReadBit(word, 2).ShouldBeTrue(); TwinCATBitAccess.ReadBit(word, 9).ShouldBeTrue(); TwinCATBitAccess.ReadBit(word, 15).ShouldBeFalse(); } /// A bit past the parent's width cannot be read — refused instead of publishing FALSE at Good. [Fact] public void CheckParent_refuses_a_bit_outside_the_parent_width() { var word = TwinCATPlcSymbolShape.Classify("UINT16", DataTypeCategory.Primitive, 2); TwinCATBitAccess.CheckParent(15, word).ShouldBeNull(); TwinCATBitAccess.CheckParent(16, word).ShouldNotBeNull(); TwinCATBitAccess.CheckParent(-1, word).ShouldNotBeNull(); } /// Only an integer word has addressable bits — a string, a float or a container is refused. [Fact] public void CheckParent_refuses_a_parent_that_is_not_an_integer_word() { TwinCATBitAccess.CheckParent(0, TwinCATPlcSymbolShape.Classify("STRING(80)", DataTypeCategory.String, 81)).ShouldNotBeNull(); TwinCATBitAccess.CheckParent(0, TwinCATPlcSymbolShape.Classify("REAL", DataTypeCategory.Primitive, 4)).ShouldNotBeNull(); TwinCATBitAccess.CheckParent(0, TwinCATPlcSymbolShape.Classify("ST_X", DataTypeCategory.Struct, 8)).ShouldNotBeNull(); TwinCATBitAccess.CheckParent(31, TwinCATPlcSymbolShape.Classify("DWORD", DataTypeCategory.Primitive, 4)).ShouldBeNull(); } // ---- #739 H18: the bit WRITE is a read-modify-write at the parent's own width ---- /// A bit write flips exactly one bit of the parent's raw bytes at the parent's width. [Fact] public void ApplyBitWrite_flips_one_bit_at_the_parent_width() { var word = TwinCATPlcSymbolShape.Classify("WORD", DataTypeCategory.Primitive, 2); TwinCATBitAccess.ApplyBitWrite(word, [0x01, 0x00], 9, true, out var set).ShouldBe(TwinCATStatusMapper.Good); set.ShouldBe(new byte[] { 0x01, 0x02 }); TwinCATBitAccess.ApplyBitWrite(word, [0xFF, 0xFF], 3, false, out var cleared).ShouldBe(TwinCATStatusMapper.Good); cleared.ShouldBe(new byte[] { 0xF7, 0xFF }); } /// /// A signed parent with its sign bit set is plain bytes — the old UDInt round-trip read it back as /// a value ≥ 2³¹ and a checked conversion turned ANY bit write into BadOutOfRange. /// [Fact] public void ApplyBitWrite_on_a_negative_signed_parent_preserves_the_sign_bit() { var intWord = TwinCATPlcSymbolShape.Classify("INT", DataTypeCategory.Primitive, 2); TwinCATBitAccess.ApplyBitWrite(intWord, [0x00, 0x80], 0, true, out var updated).ShouldBe(TwinCATStatusMapper.Good); updated.ShouldBe(new byte[] { 0x01, 0x80 }); } /// A bit beyond the parent is BadOutOfRange; a non-integer parent BadTypeMismatch; nothing is produced. [Fact] public void ApplyBitWrite_refuses_a_bit_the_parent_does_not_have() { var word = TwinCATPlcSymbolShape.Classify("UINT", DataTypeCategory.Primitive, 2); TwinCATBitAccess.ApplyBitWrite(word, [0, 0], 16, true, out var none).ShouldBe(TwinCATStatusMapper.BadOutOfRange); none.ShouldBeEmpty(); var real = TwinCATPlcSymbolShape.Classify("REAL", DataTypeCategory.Primitive, 4); TwinCATBitAccess.ApplyBitWrite(real, [0, 0, 0, 0], 1, true, out _).ShouldBe(TwinCATStatusMapper.BadTypeMismatch); } /// A parent read shorter than the parent's width is never written back (it would zero bytes). [Fact] public void ApplyBitWrite_refuses_a_short_parent_buffer() { var dword = TwinCATPlcSymbolShape.Classify("DWORD", DataTypeCategory.Primitive, 4); TwinCATBitAccess.ApplyBitWrite(dword, [0xFF, 0xFF], 1, true, out var none) .ShouldBe(TwinCATStatusMapper.BadCommunicationError); none.ShouldBeEmpty(); } }