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 { /// Test implementation of IAbCipTemplateReader. private sealed class FakeTemplateReader : IAbCipTemplateReader { /// Gets or sets the response bytes to return. public byte[] Response { get; set; } = []; /// Gets the count of read operations. public int ReadCount { get; private set; } /// Gets a value indicating whether the reader has been disposed. public bool Disposed { get; private set; } /// Gets the last template ID read. public uint LastTemplateId { get; private set; } /// Reads the template data for the specified device and template ID. /// The device parameters. /// The template instance ID. /// The cancellation token. /// A task that returns the template response bytes. public Task ReadAsync(AbCipTagCreateParams deviceParams, uint templateInstanceId, CancellationToken ct) { ReadCount++; LastTemplateId = templateInstanceId; return Task.FromResult(Response); } /// Disposes the reader. public void Dispose() => Disposed = true; } /// Test factory for creating fake template readers. private sealed class FakeTemplateReaderFactory : IAbCipTemplateReaderFactory { /// Gets the list of created readers. public List Readers { get; } = new(); /// Gets or sets an optional customization function for reader creation. public Func? Customise { get; set; } /// Creates a new template reader. /// The created reader. 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])!; } /// Verifies that FetchUdtShapeAsync decodes a blob and caches the result. [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); } /// Verifies that different template IDs result in separate fetch operations. [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); } /// Verifies that FetchUdtShapeAsync returns null for an unknown device. [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(); } /// Verifies that a decode failure returns null and does not cache the result. [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); } /// Verifies that a reader exception returns null. [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(); } /// Verifies that FlushOptionalCachesAsync empties the template cache. [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); } /// Test implementation of IAbCipTemplateReader that throws on read. private sealed class ThrowingTemplateReader : IAbCipTemplateReader { /// Throws an exception when read is attempted. /// The device parameters. /// The template ID. /// The cancellation token. /// Never returns; throws instead. public Task ReadAsync(AbCipTagCreateParams p, uint id, CancellationToken ct) => throw new InvalidOperationException("fake read failure"); /// Disposes the reader. public void Dispose() { } } }