d758dd1e16
Review r0 I3. The array encoder used the AUTHORED dataType's width while both runtimes size their buffer from the FILE: an Int array on F8 wrote four shorts into a 16-byte float buffer and sent it all (garbage neighbours, answered Good); Long/Float on N overran it. - The write path resolves the element width from the data file the address names (N/B/A/I/O/S 16-bit word, L 32-bit, F float) and encodes at THAT width. - An authored type that does not match its file is refused BadTypeMismatch before any runtime exists (warned once, no health write). - The same rule (AbLegacyTagDefinitionFactory.ArrayFileMismatch) backs an Inspect deploy warning and the AdminUI model's Validate error; FromTagConfig still accepts the tag so persisted configs keep loading. - M6: the skipped F1 parity test gains the shapes the write path refuses (/bit, .sub, indirect, type/file mismatch, PLC-5 native). Tests: one case per file type, a mismatch per pairing with nothing created, and a positive control. Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
246 lines
11 KiB
C#
246 lines
11 KiB
C#
using System.Buffers.Binary;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
|
|
|
/// <summary>
|
|
/// Review 07 U10 (user decision "implement array encode now"): an array write used to encode a SCALAR — an
|
|
/// <c>int[]</c> threw <see cref="InvalidCastException"/> into <c>BadTypeMismatch</c> + Degraded, and a scalar to an
|
|
/// array tag wrote element 0 (native) or re-sent the stale last-read buffer (libplctag). The driver now encodes
|
|
/// exactly <c>count</c> elements at the element width, converting each element (ScadaBridge writes CLR lists
|
|
/// such as <c>int[]</c> to Int16 nodes), and refuses a length mismatch with <c>BadOutOfRange</c>.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class AbLegacyArrayWriteTests
|
|
{
|
|
private const string Host = "ab://10.0.0.5/1,0";
|
|
|
|
private sealed class RecordingTag(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p)
|
|
{
|
|
public Array? Encoded { get; private set; }
|
|
public AbLegacyDataType? EncodedType { get; private set; }
|
|
|
|
public override void EncodeArray(AbLegacyDataType type, Array values)
|
|
{
|
|
EncodedType = type;
|
|
Encoded = values;
|
|
}
|
|
}
|
|
|
|
private static async Task<(AbLegacyDriver Drv, FakeAbLegacyTagFactory Factory)> NewAsync(
|
|
AbLegacyDataType type, string address, int length)
|
|
{
|
|
var factory = new FakeAbLegacyTagFactory { Customise = p => new RecordingTag(p) };
|
|
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
|
{
|
|
Devices = [new AbLegacyDeviceOptions(Host)],
|
|
RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("Arr", Host, address, type, ArrayLength: length)),
|
|
Probe = new AbLegacyProbeOptions { Enabled = false },
|
|
}, "drv-u10", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
return (drv, factory);
|
|
}
|
|
|
|
public static TheoryData<string> IntPayloads => ["int[]", "short[]", "List<int>", "object[]"];
|
|
|
|
private static object Payload(string kind) => kind switch
|
|
{
|
|
"int[]" => new[] { 1, -2, 3 }, // ScadaBridge: CLR int[] to an Int16 node
|
|
"short[]" => new short[] { 1, -2, 3 },
|
|
"List<int>" => new List<int> { 1, -2, 3 },
|
|
_ => new object[] { 1L, (short)-2, 3.0 },
|
|
};
|
|
|
|
[Theory]
|
|
[MemberData(nameof(IntPayloads))]
|
|
public async Task Int_array_write_converts_each_element_and_encodes_count_elements(string kind)
|
|
{
|
|
var (drv, f) = await NewAsync(AbLegacyDataType.Int, "N7:0", 3);
|
|
|
|
var r = await drv.WriteAsync([new WriteRequest("Arr", Payload(kind))], CancellationToken.None);
|
|
|
|
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
|
var tag = (RecordingTag)f.Tags["N7:0"];
|
|
tag.CreationParams.ElementCount.ShouldBe(3);
|
|
tag.EncodedType.ShouldBe(AbLegacyDataType.Int);
|
|
tag.Encoded.ShouldBe(new short[] { 1, -2, 3 });
|
|
tag.WriteCount.ShouldBe(1);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
}
|
|
|
|
/// <summary>Review 07 r0 I3: every data file type encodes at ITS element width (one case per file).</summary>
|
|
[Theory]
|
|
[InlineData("N7:0", AbLegacyDataType.Int, AbLegacyDataType.Int)]
|
|
[InlineData("N7:0", AbLegacyDataType.AnalogInt, AbLegacyDataType.Int)]
|
|
[InlineData("B3:0", AbLegacyDataType.Int, AbLegacyDataType.Int)]
|
|
[InlineData("A9:0", AbLegacyDataType.Int, AbLegacyDataType.Int)]
|
|
[InlineData("L9:0", AbLegacyDataType.Long, AbLegacyDataType.Long)]
|
|
[InlineData("F8:0", AbLegacyDataType.Float, AbLegacyDataType.Float)]
|
|
public async Task Each_file_type_encodes_at_the_file_element_width(string address, AbLegacyDataType authored, AbLegacyDataType fileWidth)
|
|
{
|
|
var (drv, f) = await NewAsync(authored, address, 2);
|
|
|
|
var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2 })], CancellationToken.None);
|
|
|
|
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
|
var tag = (RecordingTag)f.Tags[address];
|
|
tag.EncodedType.ShouldBe(fileWidth);
|
|
tag.Encoded!.GetType().ShouldBe(fileWidth switch
|
|
{
|
|
AbLegacyDataType.Long => typeof(int[]),
|
|
AbLegacyDataType.Float => typeof(float[]),
|
|
_ => typeof(short[]),
|
|
});
|
|
tag.WriteCount.ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Review 07 r0 I3: an array whose authored type does not match its data file (Int on F8, Long on N7, Float on
|
|
/// L9) used to be encoded at the AUTHORED width into the file-typed buffer and sent — garbage in the
|
|
/// neighbouring elements, answered Good. It is now refused BadTypeMismatch before any runtime exists.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData("F8:0", AbLegacyDataType.Int)]
|
|
[InlineData("N7:0", AbLegacyDataType.Long)]
|
|
[InlineData("N7:0", AbLegacyDataType.Float)]
|
|
[InlineData("L9:0", AbLegacyDataType.Float)]
|
|
[InlineData("L9:0", AbLegacyDataType.Int)]
|
|
public async Task A_type_that_does_not_match_its_file_is_refused_and_never_written(string address, AbLegacyDataType authored)
|
|
{
|
|
var (drv, f) = await NewAsync(authored, address, 4);
|
|
|
|
var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3, 4 })], CancellationToken.None);
|
|
|
|
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTypeMismatch);
|
|
f.Tags.ShouldBeEmpty(); // nothing created, nothing on the wire
|
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
AbLegacyTagDefinitionFactory.Inspect(
|
|
$$"""{"address":"{{address}}","dataType":"{{authored}}","isArray":true,"arrayLength":4}""")
|
|
.ShouldContain(w => w.Contains("element width")); // and the deploy gate names it
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Positive_control_a_Float_array_on_an_F_file_is_written()
|
|
{
|
|
var (drv, f) = await NewAsync(AbLegacyDataType.Float, "F8:0", 4);
|
|
(await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3, 4 })], CancellationToken.None))[0]
|
|
.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
|
((RecordingTag)f.Tags["F8:0"]).Encoded.ShouldBe(new[] { 1f, 2f, 3f, 4f });
|
|
AbLegacyTagDefinitionFactory.Inspect("""{"address":"F8:0","dataType":"Float","isArray":true,"arrayLength":4}""")
|
|
.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Float_and_Long_arrays_encode_at_their_element_width()
|
|
{
|
|
var (fd, ff) = await NewAsync(AbLegacyDataType.Float, "F8:0", 2);
|
|
(await fd.WriteAsync([new WriteRequest("Arr", new[] { 1.5, 2 })], CancellationToken.None))[0]
|
|
.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
|
((RecordingTag)ff.Tags["F8:0"]).Encoded.ShouldBe(new[] { 1.5f, 2f });
|
|
|
|
var (ld, lf) = await NewAsync(AbLegacyDataType.Long, "L9:0", 2);
|
|
(await ld.WriteAsync([new WriteRequest("Arr", new short[] { 7, -8 })], CancellationToken.None))[0]
|
|
.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
|
((RecordingTag)lf.Tags["L9:0"]).Encoded.ShouldBe(new[] { 7, -8 });
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(2)]
|
|
[InlineData(4)]
|
|
public async Task Length_mismatch_is_BadOutOfRange_and_nothing_is_sent(int supplied)
|
|
{
|
|
var (drv, f) = await NewAsync(AbLegacyDataType.Int, "N7:0", 3);
|
|
|
|
var r = await drv.WriteAsync([new WriteRequest("Arr", new int[supplied])], CancellationToken.None);
|
|
|
|
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange);
|
|
f.Tags.Values.ShouldAllBe(t => t.WriteCount == 0);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Element_overflow_is_BadOutOfRange_and_nothing_is_sent()
|
|
{
|
|
var (drv, f) = await NewAsync(AbLegacyDataType.Int, "N7:0", 2);
|
|
|
|
var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 70000 })], CancellationToken.None);
|
|
|
|
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange);
|
|
f.Tags.Values.ShouldAllBe(t => t.WriteCount == 0);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(5)]
|
|
[InlineData("1,2")]
|
|
public async Task Non_array_payload_or_unconvertible_element_is_BadTypeMismatch(object payload)
|
|
{
|
|
var (drv, f) = await NewAsync(AbLegacyDataType.Int, "N7:0", 2);
|
|
|
|
var r = await drv.WriteAsync([new WriteRequest("Arr", payload)], CancellationToken.None);
|
|
var e = await drv.WriteAsync([new WriteRequest("Arr", new object[] { 1, "x" })], CancellationToken.None);
|
|
|
|
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTypeMismatch);
|
|
e[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTypeMismatch);
|
|
f.Tags.Values.ShouldAllBe(t => t.WriteCount == 0);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Bit_array_write_is_refused_BadNotSupported_before_any_runtime()
|
|
{
|
|
// Bit arrays are bit-packed in words: an encode of N bits would rewrite whole words from a stale read.
|
|
var (drv, f) = await NewAsync(AbLegacyDataType.Bit, "B3:0", 16);
|
|
|
|
var r = await drv.WriteAsync([new WriteRequest("Arr", new bool[16])], CancellationToken.None);
|
|
|
|
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
|
|
f.Tags.ShouldBeEmpty();
|
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Native_array_encode_writes_every_element_at_its_offset()
|
|
{
|
|
var channel = new FakeCipChannel();
|
|
await using var scheduler = new RequestScheduler(channel, allowPacking: false);
|
|
var rt = new NativeAbLegacyTagRuntime(
|
|
new AbLegacyTagCreateParams("10.0.0.1", 44818, "1,0", "slc500", "N7:0", TimeSpan.FromSeconds(2), 3),
|
|
scheduler, 0xF33D, 0x21504345);
|
|
channel.Respond = req => Task.FromResult(NativeEcho(req));
|
|
|
|
rt.EncodeArray(AbLegacyDataType.Int, new short[] { 10, -1, 300 });
|
|
await rt.WriteAsync(CancellationToken.None);
|
|
|
|
var data = channel.Sent.Single()[23..];
|
|
data.Length.ShouldBe(6);
|
|
BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(0)).ShouldBe((short)10);
|
|
BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(2)).ShouldBe((short)-1);
|
|
BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(4)).ShouldBe((short)300);
|
|
rt.Dispose();
|
|
}
|
|
|
|
[Fact]
|
|
public void Native_array_encode_refuses_a_length_other_than_the_runtime_count()
|
|
{
|
|
var channel = new FakeCipChannel();
|
|
var scheduler = new RequestScheduler(channel, allowPacking: false);
|
|
var rt = new NativeAbLegacyTagRuntime(
|
|
new AbLegacyTagCreateParams("10.0.0.1", 44818, "1,0", "slc500", "F8:0", TimeSpan.FromSeconds(2), 3),
|
|
scheduler, 0xF33D, 0x21504345);
|
|
|
|
Should.Throw<ArgumentException>(() => rt.EncodeArray(AbLegacyDataType.Float, new float[2]));
|
|
rt.Dispose();
|
|
}
|
|
|
|
// Execute PCCC reply echoing the request's requestor id and TNS with STS 0 and no data.
|
|
private static byte[] NativeEcho(byte[] req)
|
|
{
|
|
var body = new List<byte>(req[6..13]) { 0x4F, 0x00, req[15], req[16] };
|
|
return FakeCipChannel.Reply(0x4B, 0x00, [.. body]);
|
|
}
|
|
}
|