using System.Buffers.Binary; using System.Reflection; using System.Text; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.AbCip; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; [Trait("Category", "Unit")] public sealed class AbCipFetchUdtShapeTests { private sealed class FakeTemplateReader : IAbCipTemplateReader { public byte[] Response { get; set; } = []; public int ReadCount { get; private set; } public bool Disposed { get; private set; } public uint LastTemplateId { get; private set; } public Task ReadAsync(AbCipTagCreateParams deviceParams, uint templateInstanceId, CancellationToken ct) { ReadCount++; LastTemplateId = templateInstanceId; return Task.FromResult(Response); } public void Dispose() => Disposed = true; } private sealed class FakeTemplateReaderFactory : IAbCipTemplateReaderFactory { public List Readers { get; } = new(); public Func? Customise { get; set; } public IAbCipTemplateReader Create() { var r = Customise?.Invoke() ?? new FakeTemplateReader(); Readers.Add(r); return r; } } private static byte[] BuildSimpleTemplate(string name, uint instanceSize, params (string n, ushort info, ushort arr, uint off)[] members) { var headerSize = 12; var blockSize = 8; var strings = new MemoryStream(); void Add(string s) { var b = Encoding.ASCII.GetBytes(s + ";\0"); strings.Write(b, 0, b.Length); } Add(name); foreach (var m in members) Add(m.n); var stringsArr = strings.ToArray(); var buf = new byte[headerSize + blockSize * members.Length + stringsArr.Length]; BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), (ushort)members.Length); BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(2), 0x1234); BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4), instanceSize); for (var i = 0; i < members.Length; i++) { var o = headerSize + i * blockSize; BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o), members[i].info); BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o + 2), members[i].arr); BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(o + 4), members[i].off); } Buffer.BlockCopy(stringsArr, 0, buf, headerSize + blockSize * members.Length, stringsArr.Length); return buf; } private static Task InvokeFetch(AbCipDriver drv, string deviceHostAddress, uint templateId) { var mi = typeof(AbCipDriver).GetMethod("FetchUdtShapeAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; return (Task)mi.Invoke(drv, [deviceHostAddress, templateId, CancellationToken.None])!; } [Fact] public async Task FetchUdtShapeAsync_decodes_blob_and_caches_result() { var factory = new FakeTemplateReaderFactory { Customise = () => new FakeTemplateReader { Response = BuildSimpleTemplate("MotorUdt", 8, ("Speed", 0xC4, 0, 0), ("Enabled", 0xC1, 0, 4)), }, }; var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], }, "drv-1", templateReaderFactory: factory); await drv.InitializeAsync("{}", CancellationToken.None); var shape = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 42); shape.ShouldNotBeNull(); shape.TypeName.ShouldBe("MotorUdt"); shape.Members.Count.ShouldBe(2); // Second fetch must hit the cache — no second reader created. _ = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 42); factory.Readers.Count.ShouldBe(1); } [Fact] public async Task FetchUdtShapeAsync_different_templateIds_each_fetch() { var callCount = 0; var factory = new FakeTemplateReaderFactory { Customise = () => { callCount++; var name = callCount == 1 ? "UdtA" : "UdtB"; return new FakeTemplateReader { Response = BuildSimpleTemplate(name, 4, ("X", 0xC4, 0, 0)), }; }, }; var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], }, "drv-1", templateReaderFactory: factory); await drv.InitializeAsync("{}", CancellationToken.None); var a = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 1); var b = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 2); a!.TypeName.ShouldBe("UdtA"); b!.TypeName.ShouldBe("UdtB"); factory.Readers.Count.ShouldBe(2); } [Fact] public async Task FetchUdtShapeAsync_unknown_device_returns_null() { var factory = new FakeTemplateReaderFactory(); var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], }, "drv-1", templateReaderFactory: factory); await drv.InitializeAsync("{}", CancellationToken.None); var shape = await InvokeFetch(drv, "ab://10.0.0.99/1,0", 1); shape.ShouldBeNull(); factory.Readers.ShouldBeEmpty(); } [Fact] public async Task FetchUdtShapeAsync_decode_failure_returns_null_and_does_not_cache() { var factory = new FakeTemplateReaderFactory { Customise = () => new FakeTemplateReader { Response = [0x00, 0x00] }, // too short }; var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], }, "drv-1", templateReaderFactory: factory); await drv.InitializeAsync("{}", CancellationToken.None); var shape = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 1); shape.ShouldBeNull(); // Next call retries (not cached as a failure). var shape2 = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 1); shape2.ShouldBeNull(); factory.Readers.Count.ShouldBe(2); } [Fact] public async Task FetchUdtShapeAsync_reader_exception_returns_null() { var factory = new FakeTemplateReaderFactory { Customise = () => new ThrowingTemplateReader(), }; var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], }, "drv-1", templateReaderFactory: factory); await drv.InitializeAsync("{}", CancellationToken.None); var shape = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 1); shape.ShouldBeNull(); } [Fact] public async Task FlushOptionalCachesAsync_empties_template_cache() { var factory = new FakeTemplateReaderFactory { Customise = () => new FakeTemplateReader { Response = BuildSimpleTemplate("U", 4, ("X", 0xC4, 0, 0)), }, }; var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], }, "drv-1", templateReaderFactory: factory); await drv.InitializeAsync("{}", CancellationToken.None); _ = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 99); drv.TemplateCache.Count.ShouldBe(1); await drv.FlushOptionalCachesAsync(CancellationToken.None); drv.TemplateCache.Count.ShouldBe(0); // Next fetch hits the network again. _ = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 99); factory.Readers.Count.ShouldBe(2); } private sealed class ThrowingTemplateReader : IAbCipTemplateReader { public Task ReadAsync(AbCipTagCreateParams p, uint id, CancellationToken ct) => throw new InvalidOperationException("fake read failure"); public void Dispose() { } } }