Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTemplateReaderTests.cs
T
Joseph Doherty 1211cd9a83 test(abcip): Task 13 review r2 - non-vacuous exclusive-packing tests
Scheduler tests (exclusive mid-queue and at head) and a template-reader test that
genuinely queues a Read Template behind a small read; bounded QueuedCount wait
replaces the fixed delay. Falsified: removing exclusive from the reader turns the
reader test red; removing the Exclusive handling in RequestScheduler turns all three red.
2026-10-07 17:10:35 -04:00

198 lines
9.6 KiB
C#

using System.Buffers.Binary;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
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.AbCip.Tests;
/// <summary>
/// Task 13: the native template reader over <see cref="FakeCipChannel"/>, serving the real
/// controller captures (rev 34.11) as Read Template reply data. The native buffer must decode
/// identically to the captured libplctag buffer.
/// </summary>
[Trait("Category", "Unit")]
public sealed class NativeAbCipTemplateReaderTests
{
private static readonly AbCipTagCreateParams Params = new(
Gateway: "10.0.0.5", Port: 44818, CipPath: "1,0", LibplctagPlcAttribute: "controllogix",
TagName: "@udt", Timeout: TimeSpan.FromSeconds(2));
/// <summary>Builds a channel serving <paramref name="captured"/> (header + raw) the way a controller would.</summary>
private static FakeCipChannel ServeTemplate(byte[] captured, int maxRequestBytes, int? statusOverrideAtFirst = null)
{
var ch = new FakeCipChannel(maxRequestBytes);
ch.RespondPerService = ServeTemplatePerService(captured, maxRequestBytes);
return ch;
}
private static Func<byte[], byte[]> ServeTemplatePerService(byte[] captured, int maxRequestBytes)
{
var id = BinaryPrimitives.ReadUInt16LittleEndian(captured);
var defWords = BinaryPrimitives.ReadUInt32LittleEndian(captured.AsSpan(2));
var instBytes = BinaryPrimitives.ReadUInt32LittleEndian(captured.AsSpan(6));
var members = BinaryPrimitives.ReadUInt16LittleEndian(captured.AsSpan(10));
var handle = BinaryPrimitives.ReadUInt16LittleEndian(captured.AsSpan(12));
var raw = captured.AsSpan(14).ToArray();
var total = (int)(defWords * 4 - 23);
return req =>
{
if (req[0] == 0x03)
{
var d = new byte[30];
var w = d.AsSpan();
BinaryPrimitives.WriteUInt16LittleEndian(w, 4);
BinaryPrimitives.WriteUInt16LittleEndian(w[2..], 4); BinaryPrimitives.WriteUInt16LittleEndian(w[4..], 0);
BinaryPrimitives.WriteUInt32LittleEndian(w[6..], defWords);
BinaryPrimitives.WriteUInt16LittleEndian(w[10..], 5); BinaryPrimitives.WriteUInt16LittleEndian(w[12..], 0);
BinaryPrimitives.WriteUInt32LittleEndian(w[14..], instBytes);
BinaryPrimitives.WriteUInt16LittleEndian(w[18..], 2); BinaryPrimitives.WriteUInt16LittleEndian(w[20..], 0);
BinaryPrimitives.WriteUInt16LittleEndian(w[22..], members);
BinaryPrimitives.WriteUInt16LittleEndian(w[24..], 1); BinaryPrimitives.WriteUInt16LittleEndian(w[26..], 0);
BinaryPrimitives.WriteUInt16LittleEndian(w[28..], handle);
return FakeCipChannel.Reply(0x03, 0, d);
}
req[0].ShouldBe((byte)0x4C);
var offset = (int)BinaryPrimitives.ReadUInt32LittleEndian(req.AsSpan(8));
var count = BinaryPrimitives.ReadUInt16LittleEndian(req.AsSpan(12));
count.ShouldBeLessThanOrEqualTo((ushort)(maxRequestBytes - 16));
var n = Math.Min(count, total - offset);
var done = offset + n >= total;
return FakeCipChannel.Reply(0x4C, done ? (byte)0x00 : (byte)0x06, raw[offset..(offset + n)]);
};
}
private static async Task<byte[]> ReadNativeAsync(FakeCipChannel ch, ushort id)
{
await using var sched = new RequestScheduler(ch, allowPacking: false);
var released = 0;
using var reader = new NativeAbCipTemplateReader(_ =>
(sched, ch, () => { released++; return ValueTask.CompletedTask; }));
var buf = await reader.ReadAsync(Params, id, TestContext.Current.CancellationToken);
released.ShouldBe(1);
return buf;
}
[Theory]
[InlineData(CipTemplateObjectWireCaptureTests.StringTemplate, 504)]
[InlineData(CipTemplateObjectWireCaptureTests.StringTemplate, 40)]
[InlineData(CipTemplateObjectWireCaptureTests.LookupMemberTemplate, 504)]
[InlineData(CipTemplateObjectWireCaptureTests.LookupMemberTemplate, 40)]
public async Task Native_buffer_decodes_identically_to_the_captured_libplctag_buffer(string hex, int maxRequest)
{
var captured = Convert.FromHexString(hex);
var ch = ServeTemplate(captured, maxRequest);
var id = BinaryPrimitives.ReadUInt16LittleEndian(captured);
var native = await ReadNativeAsync(ch, id);
var expected = CipTemplateObjectDecoder.Decode(captured);
var actual = CipTemplateObjectDecoder.Decode(native);
expected.ShouldNotBeNull();
actual.ShouldNotBeNull();
actual.TypeName.ShouldBe(expected.TypeName);
actual.TotalSize.ShouldBe(expected.TotalSize);
actual.Members.Count.ShouldBe(expected.Members.Count);
for (var i = 0; i < expected.Members.Count; i++)
{
actual.Members[i].Name.ShouldBe(expected.Members[i].Name);
actual.Members[i].DataType.ShouldBe(expected.Members[i].DataType);
actual.Members[i].Offset.ShouldBe(expected.Members[i].Offset);
actual.Members[i].ArrayLength.ShouldBe(expected.Members[i].ArrayLength);
}
// The 14-byte header is byte for byte the libplctag one.
native.AsSpan(0, 14).SequenceEqual(captured.AsSpan(0, 14)).ShouldBeTrue();
}
[Fact]
public async Task A_small_request_limit_forces_several_chunks_each_within_the_limit()
{
var captured = Convert.FromHexString(CipTemplateObjectWireCaptureTests.LookupMemberTemplate);
var ch = ServeTemplate(captured, 40);
await ReadNativeAsync(ch, 3386);
ch.Sent.Count(r => r[0] == 0x4C).ShouldBeGreaterThan(2);
}
[Fact]
public async Task A_failing_read_template_status_throws_and_still_releases_the_lease()
{
var captured = Convert.FromHexString(CipTemplateObjectWireCaptureTests.StringTemplate);
var ch = ServeTemplate(captured, 504);
var inner = ch.RespondPerService;
ch.RespondPerService = req => req[0] == 0x4C ? FakeCipChannel.Reply(0x4C, 0x05, []) : inner(req);
await using var sched = new RequestScheduler(ch, allowPacking: false);
var released = 0;
using var reader = new NativeAbCipTemplateReader(_ =>
(sched, ch, () => { released++; return ValueTask.CompletedTask; }));
await Should.ThrowAsync<AbTransportException>(() =>
reader.ReadAsync(Params, 0x0FCE, TestContext.Current.CancellationToken));
released.ShouldBe(1);
}
[Fact]
public async Task A_read_template_queued_behind_a_small_read_on_a_packing_scheduler_goes_out_alone()
{
var captured = Convert.FromHexString(CipTemplateObjectWireCaptureTests.LookupMemberTemplate);
var ch = ServeTemplate(captured, 504);
await using var sched = new RequestScheduler(ch, allowPacking: true);
var ct = TestContext.Current.CancellationToken;
var small = new byte[] { 0x0E, 0x02, 0x20, 0x01, 0x24, 0x01 }; // Get Attribute Single
var hold = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var inner = ch.RespondPerService;
var firstSmall = true;
Task<CipReply>? s1 = null;
ch.Respond = async req =>
{
// The Get Attributes reply is delivered with a small read already queued behind it, so the
// pump takes that read next (S1) and holds it in flight while the reader submits its first
// Read Template: that template is genuinely queued behind another request.
if (req[0] == 0x03)
{
s1 = sched.SendAsync(small, ct);
return await ch.DefaultRespond(req);
}
if (req[0] == 0x0E && firstSmall)
{
firstSmall = false;
await hold.Task;
}
return await ch.DefaultRespond(req);
};
ch.RespondPerService = r => r[0] == 0x0E ? FakeCipChannel.OkReply(r) : inner(r);
using var reader = new NativeAbCipTemplateReader(_ => (sched, ch, () => ValueTask.CompletedTask));
var read = reader.ReadAsync(Params, 3386, ct);
await ch.WaitForSendsAsync(2); // Get Attributes, then S1 held in flight
var s2 = sched.SendAsync(small, ct);
using var bound = CancellationTokenSource.CreateLinkedTokenSource(ct);
bound.CancelAfter(TimeSpan.FromSeconds(10));
while (sched.QueuedCount < 2) await Task.Delay(1, bound.Token); // S2 + the first Read Template
hold.SetResult();
await read;
await Task.WhenAll(s1!, s2);
ch.Sent.Where(r => r[0] == 0x0A).ShouldAllBe(msp => FakeCipChannel.SplitMsp(msp).All(e => e[0] != 0x4C));
ch.Sent.ShouldContain(r => r[0] == 0x4C); // the Read Template requests went out bare
}
[Fact]
public async Task A_template_size_over_the_bound_is_rejected()
{
var captured = Convert.FromHexString(CipTemplateObjectWireCaptureTests.StringTemplate);
BinaryPrimitives.WriteUInt32LittleEndian(captured.AsSpan(2), 0x10000000);
var ch = ServeTemplate(captured, 504);
await using var sched = new RequestScheduler(ch, allowPacking: false);
using var reader = new NativeAbCipTemplateReader(_ => (sched, ch, () => ValueTask.CompletedTask));
var ex = await Should.ThrowAsync<AbTransportException>(
() => reader.ReadAsync(Params, 0x0FCE, TestContext.Current.CancellationToken));
ex.Status.ShouldBe(AbStatus.BadReply);
ch.Sent.ShouldNotContain(r => r[0] == 0x4C);
}
}