1211cd9a83
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.
198 lines
9.6 KiB
C#
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);
|
|
}
|
|
}
|