301 lines
13 KiB
C#
301 lines
13 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
|
|
|
/// <summary>
|
|
/// Unit tests for the S7 1-D array support: the pure
|
|
/// <see cref="S7Driver.DecodeArrayBlock"/> decode loop (the half of the contiguous
|
|
/// block read that turns a raw S7 big-endian byte block into a typed CLR array — the
|
|
/// network I/O half, <c>Plc.ReadBytesAsync</c>, has no in-process fake so only the
|
|
/// decode is unit-proven), the <see cref="ITagDiscovery"/> <c>IsArray</c>/<c>ArrayDim</c>
|
|
/// flip, and the equipment-tag resolver threading <c>arrayLength</c> into the transient
|
|
/// tag-def's array count.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class S7ArrayReadTests
|
|
{
|
|
// ── Helpers ──────────────────────────────────────────────────────────────────────────
|
|
|
|
private static S7TagDefinition ArrTag(S7DataType dt, int count) =>
|
|
new("ArrTag", "DB1.DBW0", dt, ArrayCount: count);
|
|
|
|
private static S7ParsedAddress Addr(S7Size size) =>
|
|
new(S7Area.DataBlock, DbNumber: 1, size, ByteOffset: 0, BitOffset: 0);
|
|
|
|
// S7 is big-endian: most-significant byte first.
|
|
private static byte[] BeWords(params ushort[] words)
|
|
{
|
|
var b = new byte[words.Length * 2];
|
|
for (var i = 0; i < words.Length; i++)
|
|
{
|
|
b[i * 2] = (byte)(words[i] >> 8);
|
|
b[i * 2 + 1] = (byte)(words[i] & 0xFF);
|
|
}
|
|
return b;
|
|
}
|
|
|
|
private static byte[] BeDwords(params uint[] dwords)
|
|
{
|
|
var b = new byte[dwords.Length * 4];
|
|
for (var i = 0; i < dwords.Length; i++)
|
|
{
|
|
b[i * 4] = (byte)(dwords[i] >> 24);
|
|
b[i * 4 + 1] = (byte)(dwords[i] >> 16);
|
|
b[i * 4 + 2] = (byte)(dwords[i] >> 8);
|
|
b[i * 4 + 3] = (byte)(dwords[i] & 0xFF);
|
|
}
|
|
return b;
|
|
}
|
|
|
|
// ── DecodeArrayBlock — element-typed CLR arrays ───────────────────────────────────────
|
|
|
|
/// <summary>Verifies an Int16 array decodes to a typed short[] with big-endian values.</summary>
|
|
[Fact]
|
|
public void DecodeArrayBlock_Int16_returns_short_array()
|
|
{
|
|
// 3 words: 1, -1 (0xFFFF), 32767 (0x7FFF).
|
|
var block = BeWords(0x0001, 0xFFFF, 0x7FFF);
|
|
var result = S7Driver.DecodeArrayBlock(ArrTag(S7DataType.Int16, 3), Addr(S7Size.Word), block);
|
|
|
|
var arr = result.ShouldBeOfType<short[]>();
|
|
arr.ShouldBe(new short[] { 1, -1, 32767 });
|
|
}
|
|
|
|
/// <summary>Verifies a UInt16 array decodes to a typed ushort[].</summary>
|
|
[Fact]
|
|
public void DecodeArrayBlock_UInt16_returns_ushort_array()
|
|
{
|
|
var block = BeWords(0, 1000, 65535);
|
|
var result = S7Driver.DecodeArrayBlock(ArrTag(S7DataType.UInt16, 3), Addr(S7Size.Word), block);
|
|
|
|
result.ShouldBeOfType<ushort[]>().ShouldBe(new ushort[] { 0, 1000, 65535 });
|
|
}
|
|
|
|
/// <summary>Verifies an Int32 array decodes to a typed int[] with big-endian dwords.</summary>
|
|
[Fact]
|
|
public void DecodeArrayBlock_Int32_returns_int_array()
|
|
{
|
|
var block = BeDwords(1u, 0xFFFF_FFFFu, 0x7FFF_FFFFu);
|
|
var result = S7Driver.DecodeArrayBlock(ArrTag(S7DataType.Int32, 3), Addr(S7Size.DWord), block);
|
|
|
|
result.ShouldBeOfType<int[]>().ShouldBe(new[] { 1, -1, int.MaxValue });
|
|
}
|
|
|
|
/// <summary>Verifies a UInt32 array decodes to a typed uint[].</summary>
|
|
[Fact]
|
|
public void DecodeArrayBlock_UInt32_returns_uint_array()
|
|
{
|
|
var block = BeDwords(0u, 70_000u, 0xFFFF_FFFFu);
|
|
var result = S7Driver.DecodeArrayBlock(ArrTag(S7DataType.UInt32, 3), Addr(S7Size.DWord), block);
|
|
|
|
result.ShouldBeOfType<uint[]>().ShouldBe(new uint[] { 0, 70_000, 0xFFFF_FFFF });
|
|
}
|
|
|
|
/// <summary>Verifies a Float32 array decodes to a typed float[] from IEEE-754 big-endian dwords.</summary>
|
|
[Fact]
|
|
public void DecodeArrayBlock_Float32_returns_float_array()
|
|
{
|
|
var bits0 = BitConverter.SingleToUInt32Bits(1.5f);
|
|
var bits1 = BitConverter.SingleToUInt32Bits(-2.25f);
|
|
var bits2 = BitConverter.SingleToUInt32Bits(3.14f);
|
|
var block = BeDwords(bits0, bits1, bits2);
|
|
|
|
var result = S7Driver.DecodeArrayBlock(ArrTag(S7DataType.Float32, 3), Addr(S7Size.DWord), block);
|
|
|
|
var arr = result.ShouldBeOfType<float[]>();
|
|
arr.Length.ShouldBe(3);
|
|
arr[0].ShouldBe(1.5f, tolerance: 1e-6f);
|
|
arr[1].ShouldBe(-2.25f, tolerance: 1e-6f);
|
|
arr[2].ShouldBe(3.14f, tolerance: 1e-6f);
|
|
}
|
|
|
|
/// <summary>Verifies a Byte array decodes to a typed byte[] (one element per byte).</summary>
|
|
[Fact]
|
|
public void DecodeArrayBlock_Byte_returns_byte_array()
|
|
{
|
|
var block = new byte[] { 0, 42, 200, 255 };
|
|
var result = S7Driver.DecodeArrayBlock(ArrTag(S7DataType.Byte, 4), Addr(S7Size.Byte), block);
|
|
|
|
result.ShouldBeOfType<byte[]>().ShouldBe(new byte[] { 0, 42, 200, 255 });
|
|
}
|
|
|
|
/// <summary>Verifies a Bool array decodes from packed bits (one byte → low bit per element).</summary>
|
|
[Fact]
|
|
public void DecodeArrayBlock_Bool_returns_bool_array()
|
|
{
|
|
// Bit array: one byte per element, low bit carries the value (S7 contiguous bit access
|
|
// is byte-granular over the wire so each element occupies its own byte slot).
|
|
var block = new byte[] { 0x01, 0x00, 0x01 };
|
|
var result = S7Driver.DecodeArrayBlock(
|
|
new S7TagDefinition("B", "DB1.DBX0.0", S7DataType.Bool, ArrayCount: 3),
|
|
new S7ParsedAddress(S7Area.DataBlock, 1, S7Size.Bit, 0, 0),
|
|
block);
|
|
|
|
result.ShouldBeOfType<bool[]>().ShouldBe(new[] { true, false, true });
|
|
}
|
|
|
|
/// <summary>Verifies the array length matches the tag's declared count.</summary>
|
|
[Fact]
|
|
public void DecodeArrayBlock_length_matches_declared_count()
|
|
{
|
|
var block = BeWords(10, 20, 30, 40, 50);
|
|
var result = S7Driver.DecodeArrayBlock(ArrTag(S7DataType.UInt16, 5), Addr(S7Size.Word), block);
|
|
|
|
result.ShouldBeOfType<ushort[]>().Length.ShouldBe(5);
|
|
}
|
|
|
|
/// <summary>Verifies unsupported element types throw NotSupportedException in the array path.</summary>
|
|
/// <param name="dt">The unsupported S7 data type.</param>
|
|
[Theory]
|
|
[InlineData(S7DataType.Int64)]
|
|
[InlineData(S7DataType.Float64)]
|
|
[InlineData(S7DataType.String)]
|
|
public void DecodeArrayBlock_unsupported_element_type_throws(S7DataType dt)
|
|
{
|
|
Should.Throw<NotSupportedException>(() =>
|
|
S7Driver.DecodeArrayBlock(ArrTag(dt, 2), Addr(S7Size.DWord), new byte[16]));
|
|
}
|
|
|
|
// ── ElementByteSize — block-read sizing ───────────────────────────────────────────────
|
|
|
|
/// <summary>Verifies element byte sizes used to size the contiguous block read.</summary>
|
|
[Theory]
|
|
[InlineData(S7Size.Bit, 1)]
|
|
[InlineData(S7Size.Byte, 1)]
|
|
[InlineData(S7Size.Word, 2)]
|
|
[InlineData(S7Size.DWord, 4)]
|
|
public void ElementByteSize_matches_size_width(S7Size size, int expected)
|
|
=> S7Driver.ElementByteSize(size).ShouldBe(expected);
|
|
|
|
// ── Discovery — IsArray / ArrayDim flip ───────────────────────────────────────────────
|
|
|
|
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
|
{
|
|
public readonly List<(string Name, DriverAttributeInfo Attr)> Variables = new();
|
|
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
|
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attr)
|
|
{
|
|
Variables.Add((browseName, attr));
|
|
return new Handle();
|
|
}
|
|
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
|
public void AttachAlarmCondition(IVariableHandle sourceVariable, string alarmName, DriverAttributeInfo alarmInfo) { }
|
|
private sealed class Handle : IVariableHandle
|
|
{
|
|
public string FullReference => "stub";
|
|
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotImplementedException();
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies an array tag is discovered with IsArray=true and ArrayDim=count.</summary>
|
|
[Fact]
|
|
public async Task DiscoverAsync_flips_IsArray_for_array_tag()
|
|
{
|
|
var opts = new S7DriverOptions
|
|
{
|
|
Host = "192.0.2.1",
|
|
Tags =
|
|
[
|
|
new("Scalar", "DB1.DBW0", S7DataType.Int16),
|
|
new("Arr", "DB1.DBW10", S7DataType.Int16, ArrayCount: 8),
|
|
],
|
|
};
|
|
using var drv = new S7Driver(opts, "s7-arr-disco");
|
|
|
|
var builder = new RecordingBuilder();
|
|
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
|
|
|
|
var scalar = builder.Variables.Single(v => v.Name == "Scalar").Attr;
|
|
scalar.IsArray.ShouldBeFalse();
|
|
scalar.ArrayDim.ShouldBeNull();
|
|
|
|
var arr = builder.Variables.Single(v => v.Name == "Arr").Attr;
|
|
arr.IsArray.ShouldBeTrue();
|
|
arr.ArrayDim.ShouldBe(8u);
|
|
}
|
|
|
|
/// <summary>Verifies a tag with ArrayCount of 1 is treated as a 1-element array (not scalar).
|
|
/// The foundation materialises a [1] OPC UA array node when IsArray=true, so the driver must
|
|
/// honour isArray:true + arrayLength:1 — a 1-element array IS a valid array.</summary>
|
|
[Fact]
|
|
public async Task DiscoverAsync_count_of_one_is_array()
|
|
{
|
|
var opts = new S7DriverOptions
|
|
{
|
|
Host = "192.0.2.1",
|
|
Tags = [new("One", "DB1.DBW0", S7DataType.Int16, ArrayCount: 1)],
|
|
};
|
|
using var drv = new S7Driver(opts, "s7-arr-one");
|
|
|
|
var builder = new RecordingBuilder();
|
|
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
|
|
|
|
var one = builder.Variables.Single().Attr;
|
|
one.IsArray.ShouldBeTrue("ArrayCount=1 is a 1-element array, not scalar");
|
|
one.ArrayDim.ShouldBe(1u);
|
|
}
|
|
|
|
/// <summary>Verifies that a tag with a null ArrayCount (scalar) is still discovered as scalar.</summary>
|
|
[Fact]
|
|
public async Task DiscoverAsync_null_ArrayCount_is_scalar()
|
|
{
|
|
var opts = new S7DriverOptions
|
|
{
|
|
Host = "192.0.2.1",
|
|
Tags = [new("Scalar", "DB1.DBW0", S7DataType.Int16, ArrayCount: null)],
|
|
};
|
|
using var drv = new S7Driver(opts, "s7-arr-null");
|
|
|
|
var builder = new RecordingBuilder();
|
|
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
|
|
|
|
var scalar = builder.Variables.Single().Attr;
|
|
scalar.IsArray.ShouldBeFalse("null ArrayCount is scalar");
|
|
scalar.ArrayDim.ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>Verifies a read of an isArray:true arrayLength:1 equipment tag returns a 1-element typed array.</summary>
|
|
[Fact]
|
|
public void DecodeArrayBlock_count_of_one_returns_single_element_array()
|
|
{
|
|
// A 1-element Int16 array: one big-endian word.
|
|
var block = BeWords(0x0064); // 100
|
|
var result = S7Driver.DecodeArrayBlock(ArrTag(S7DataType.Int16, 1), Addr(S7Size.Word), block);
|
|
|
|
var arr = result.ShouldBeOfType<short[]>();
|
|
arr.Length.ShouldBe(1);
|
|
arr[0].ShouldBe((short)100);
|
|
}
|
|
|
|
// ── Equipment-tag resolver threads arrayLength → ArrayCount ───────────────────────────
|
|
|
|
/// <summary>Verifies the equipment-tag parser threads isArray/arrayLength into ArrayCount.</summary>
|
|
[Fact]
|
|
public void EquipmentTagParser_threads_array_length_into_ArrayCount()
|
|
{
|
|
var json = """{"address":"DB1.DBW0","dataType":"Int16","isArray":true,"arrayLength":16}""";
|
|
S7EquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
|
|
def!.ArrayCount.ShouldBe(16);
|
|
}
|
|
|
|
/// <summary>Verifies arrayLength is ignored when isArray is false (mirrors the sink foundation).</summary>
|
|
[Fact]
|
|
public void EquipmentTagParser_ignores_arrayLength_when_isArray_false()
|
|
{
|
|
var json = """{"address":"DB1.DBW0","dataType":"Int16","isArray":false,"arrayLength":16}""";
|
|
S7EquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
|
|
def!.ArrayCount.ShouldBeNull("arrayLength is honoured only when isArray is true");
|
|
}
|
|
|
|
/// <summary>Verifies a scalar equipment tag (no array keys) has a null ArrayCount.</summary>
|
|
[Fact]
|
|
public void EquipmentTagParser_scalar_has_null_ArrayCount()
|
|
{
|
|
var json = """{"address":"DB1.DBW0","dataType":"Int16"}""";
|
|
S7EquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
|
|
def!.ArrayCount.ShouldBeNull();
|
|
}
|
|
}
|