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; } } /// /// Build a template buffer in libplctag's real @udt/id layout via /// . info is the member's CIP type word; arr /// > 0 marks an array member. /// private static byte[] BuildSimpleTemplate( string name, uint instanceSize, params (string n, ushort info, ushort arr, uint off)[] members) => TemplateBufferBuilder.Build(name, instanceSize, [.. members.Select(m => (m.n, m.info, m.arr, m.off))]); 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 a cleared template cache is re-populated from the network. [Fact] public async Task A_cleared_template_cache_refetches_from_the_controller() { 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); // Gitea #525 deleted AbCipDriver.FlushOptionalCachesAsync, which is what used to clear this; // the cache's own Clear() is now the only reset, and nothing in production calls it (see the // AbCipTemplateCache remarks). What this test still pins is the cache-miss path below. drv.TemplateCache.Clear(); 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() { } } }