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);
}
}