using S7.Net; using S7.Net.Types; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; /// /// Unit tests for the multi-variable PDU packer (PR-S7-B1). Exercises the static /// packer surface — bin packing, packability classification, DataItem construction, /// and value decode — without needing a live PLC. The wire-level round-trip is /// covered indirectly via the existing single-tag tests; this file pins the /// coalescing math so 100 scalar reads land in ⌈100/19⌉ = 6 PDUs at 240-byte PDU. /// [Trait("Category", "Unit")] public sealed class S7ReadPackerTests { [Fact] public void ItemBudget_at_default_240_byte_pdu_is_18() { // 240-byte PDU response: floor((240 - 18) / 12) = 18. The Siemens spec puts // the practical Step-7 ceiling at 19, but the math from the budget formula // (response = negotiatedPduSize − 18 − 12·N ≥ 0) gives 18 — stay with the // honest math, leaving one item of slack for the per-PDU response framing. S7ReadPacker.ItemBudget(240).ShouldBe(18); } [Fact] public void ItemBudget_at_960_byte_pdu_caps_at_practical_max() { // Even though a 960-byte PDU could mathematically fit ~78 items, many CPU // firmwares reject >20 multi-var items regardless of PDU size. The packer // caps at MaxItemsPerPdu240 = 19 to stay safe under that ceiling. S7ReadPacker.ItemBudget(960).ShouldBe(S7ReadPacker.MaxItemsPerPdu240); } [Fact] public void ItemBudget_with_tiny_pdu_returns_at_least_one() { // Pathological negotiated PDU smaller than the response header should still // yield a budget of 1 so the driver doesn't divide-by-zero or stall. S7ReadPacker.ItemBudget(8).ShouldBe(1); } [Fact] public void BinPack_packs_100_items_into_six_batches_at_default_pdu() { // The headline coalescing claim: 100 scalar reads coalesce into ≤6 PDU calls // at the default 240-byte PDU (5 × 18 + 10 = 100). The previous per-tag loop // would issue 100 round-trips for the same input. var indices = Enumerable.Range(0, 100).ToList(); var budget = S7ReadPacker.ItemBudget(240); var batches = S7ReadPacker.BinPack(indices, budget); batches.Count.ShouldBeLessThanOrEqualTo(6); batches.ShouldAllBe(b => b.Count <= budget); batches.SelectMany(b => b).ShouldBe(indices); } [Fact] public void BinPack_preserves_input_order_within_batches() { // The ReadAsync result array indexes by the original caller's order; bin // packing must therefore preserve order so DataItem[k] in the response maps // back to batch[k] which maps back to caller-index. var indices = new List { 7, 3, 11, 1, 5, 13, 9 }; var batches = S7ReadPacker.BinPack(indices, itemBudget: 3); batches.Count.ShouldBe(3); batches[0].ShouldBe(new[] { 7, 3, 11 }); batches[1].ShouldBe(new[] { 1, 5, 13 }); batches[2].ShouldBe(new[] { 9 }); } [Theory] [InlineData(S7DataType.Bool, S7Size.Bit, true)] [InlineData(S7DataType.Byte, S7Size.Byte, true)] [InlineData(S7DataType.Int16, S7Size.Word, true)] [InlineData(S7DataType.UInt16, S7Size.Word, true)] [InlineData(S7DataType.Int32, S7Size.DWord, true)] [InlineData(S7DataType.UInt32, S7Size.DWord, true)] [InlineData(S7DataType.Float32, S7Size.DWord, true)] [InlineData(S7DataType.Float64, S7Size.LWord, true)] // Types with no native VarType in S7.Net's multi-var path — must fall back. [InlineData(S7DataType.Int64, S7Size.LWord, false)] [InlineData(S7DataType.UInt64, S7Size.LWord, false)] [InlineData(S7DataType.String, S7Size.Byte, false)] [InlineData(S7DataType.WString, S7Size.Byte, false)] [InlineData(S7DataType.Char, S7Size.Byte, false)] [InlineData(S7DataType.Dtl, S7Size.Byte, false)] [InlineData(S7DataType.Date, S7Size.Word, false)] [InlineData(S7DataType.Time, S7Size.DWord, false)] public void IsPackable_classifies_known_scalar_types(S7DataType type, S7Size size, bool expected) { var tag = new S7TagDefinition("t", "DB1.DBW0", type); var addr = new S7ParsedAddress(S7Area.DataBlock, DbNumber: 1, size, ByteOffset: 0, BitOffset: 0); S7ReadPacker.IsPackable(tag, addr).ShouldBe(expected); } [Fact] public void IsPackable_rejects_arrays_regardless_of_element_type() { // 1-D arrays go through the byte-range path (PR-S7-A4) — the multi-var // surface can't request "N×elementBytes from offset" as a single DataItem. var tag = new S7TagDefinition("a", "DB1.DBW0", S7DataType.Int16, ElementCount: 4); var addr = new S7ParsedAddress(S7Area.DataBlock, 1, S7Size.Word, 0, 0); S7ReadPacker.IsPackable(tag, addr).ShouldBeFalse(); } [Fact] public void BuildDataItem_for_DB_word_uses_DataBlock_area_and_Word_VarType() { var tag = new S7TagDefinition("t", "DB7.DBW10", S7DataType.UInt16); var addr = new S7ParsedAddress(S7Area.DataBlock, DbNumber: 7, S7Size.Word, ByteOffset: 10, BitOffset: 0); var item = S7ReadPacker.BuildDataItem(tag, addr); item.DataType.ShouldBe(DataType.DataBlock); item.VarType.ShouldBe(VarType.Word); item.DB.ShouldBe(7); item.StartByteAdr.ShouldBe(10); item.BitAdr.ShouldBe((byte)0); item.Count.ShouldBe(1); } [Fact] public void BuildDataItem_for_bit_address_carries_BitAdr() { var tag = new S7TagDefinition("t", "M0.3", S7DataType.Bool); var addr = new S7ParsedAddress(S7Area.Memory, DbNumber: 0, S7Size.Bit, ByteOffset: 0, BitOffset: 3); var item = S7ReadPacker.BuildDataItem(tag, addr); item.DataType.ShouldBe(DataType.Memory); item.VarType.ShouldBe(VarType.Bit); item.BitAdr.ShouldBe((byte)3); } [Fact] public void BuildDataItem_for_lreal_uses_LReal_VarType() { var tag = new S7TagDefinition("t", "DB1.DBLD0", S7DataType.Float64); var addr = new S7ParsedAddress(S7Area.DataBlock, 1, S7Size.LWord, 0, 0); var item = S7ReadPacker.BuildDataItem(tag, addr); item.VarType.ShouldBe(VarType.LReal); } [Fact] public void DecodePackedValue_reinterprets_Word_as_Int16_with_sign() { // S7.Net surfaces Word as ushort; tags declared Int16 reinterpret the bit // pattern as a signed short — the same reinterpret the single-tag ReadOneAsync // path applies, so packed and per-tag results match for the same wire bytes. var tag = new S7TagDefinition("t", "DB1.DBW0", S7DataType.Int16); var decoded = S7ReadPacker.DecodePackedValue(tag, (ushort)0xFFFF); decoded.ShouldBe((short)-1); } [Fact] public void DecodePackedValue_reinterprets_DWord_as_Int32_with_sign() { var tag = new S7TagDefinition("t", "DB1.DBD0", S7DataType.Int32); var decoded = S7ReadPacker.DecodePackedValue(tag, 0x80000000u); decoded.ShouldBe(int.MinValue); } [Fact] public void DecodePackedValue_passes_through_native_Real_double() { var tag = new S7TagDefinition("t", "DB1.DBD0", S7DataType.Float32); var decoded = S7ReadPacker.DecodePackedValue(tag, 1.5f); decoded.ShouldBe(1.5f); } }