Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipFetchUdtShapeTests.cs
T
Joseph Doherty 3b48e05e9a fix(abcip): decode real @udt template buffers; browse STRINGs as String, hide BOOL hosts
Live read-only check against production CompactLogix 5069-L320ERS2 rev 34.11 presses
(Z-35162 / 10.209.70.160): every @udt/<id> template the controller returned decoded to
null, so controller-discovered UDTs never fanned out. The decoder assumed a 12-byte
header led by the member count and [info][array][offset] blocks with ';'-terminated
names; libplctag actually returns a 14-byte header (u16 id, u32 def words, u32 instance
size, u16 member count, u16 handle), [info][type][offset] blocks, and NUL-terminated
names after a 'Name;<tail>' type name. All three test builders restated the old
assumption, so the unit suite agreed with itself (the #468 trap again); they now share a
TemplateBufferBuilder pinned by real captures (CipTemplateObjectWireCaptureTests).

Discovery follow-ons that the working decoder exposed:
- STRING (and user string types, LEN DINT + DATA SINT[n]) browse as one String variable,
  top-level and nested, not as a LEN/DATA folder.
- Controller-generated members (ZZZZZZZZZZ* BOOL hosts, __* AOI backing) are not browsed.
- A UDT array is not fanned out into element-free Tag.Member paths.
2026-10-07 14:04:11 -04:00

243 lines
9.7 KiB
C#

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
{
/// <summary>Test implementation of IAbCipTemplateReader.</summary>
private sealed class FakeTemplateReader : IAbCipTemplateReader
{
/// <summary>Gets or sets the response bytes to return.</summary>
public byte[] Response { get; set; } = [];
/// <summary>Gets the count of read operations.</summary>
public int ReadCount { get; private set; }
/// <summary>Gets a value indicating whether the reader has been disposed.</summary>
public bool Disposed { get; private set; }
/// <summary>Gets the last template ID read.</summary>
public uint LastTemplateId { get; private set; }
/// <summary>Reads the template data for the specified device and template ID.</summary>
/// <param name="deviceParams">The device parameters.</param>
/// <param name="templateInstanceId">The template instance ID.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that returns the template response bytes.</returns>
public Task<byte[]> ReadAsync(AbCipTagCreateParams deviceParams, uint templateInstanceId, CancellationToken ct)
{
ReadCount++;
LastTemplateId = templateInstanceId;
return Task.FromResult(Response);
}
/// <summary>Disposes the reader.</summary>
public void Dispose() => Disposed = true;
}
/// <summary>Test factory for creating fake template readers.</summary>
private sealed class FakeTemplateReaderFactory : IAbCipTemplateReaderFactory
{
/// <summary>Gets the list of created readers.</summary>
public List<IAbCipTemplateReader> Readers { get; } = new();
/// <summary>Gets or sets an optional customization function for reader creation.</summary>
public Func<IAbCipTemplateReader>? Customise { get; set; }
/// <summary>Creates a new template reader.</summary>
/// <returns>The created reader.</returns>
public IAbCipTemplateReader Create()
{
var r = Customise?.Invoke() ?? new FakeTemplateReader();
Readers.Add(r);
return r;
}
}
/// <summary>
/// Build a template buffer in libplctag's real <c>@udt/id</c> layout via
/// <see cref="TemplateBufferBuilder"/>. <c>info</c> is the member's CIP type word; <c>arr</c>
/// &gt; 0 marks an array member.
/// </summary>
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<AbCipUdtShape?> InvokeFetch(AbCipDriver drv, string deviceHostAddress, uint templateId)
{
var mi = typeof(AbCipDriver).GetMethod("FetchUdtShapeAsync",
BindingFlags.NonPublic | BindingFlags.Instance)!;
return (Task<AbCipUdtShape?>)mi.Invoke(drv, [deviceHostAddress, templateId, CancellationToken.None])!;
}
/// <summary>Verifies that FetchUdtShapeAsync decodes a blob and caches the result.</summary>
[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);
}
/// <summary>Verifies that different template IDs result in separate fetch operations.</summary>
[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);
}
/// <summary>Verifies that FetchUdtShapeAsync returns null for an unknown device.</summary>
[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();
}
/// <summary>Verifies that a decode failure returns null and does not cache the result.</summary>
[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);
}
/// <summary>Verifies that a reader exception returns null.</summary>
[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();
}
/// <summary>Verifies that a cleared template cache is re-populated from the network.</summary>
[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);
}
/// <summary>Test implementation of IAbCipTemplateReader that throws on read.</summary>
private sealed class ThrowingTemplateReader : IAbCipTemplateReader
{
/// <summary>Throws an exception when read is attempted.</summary>
/// <param name="p">The device parameters.</param>
/// <param name="id">The template ID.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>Never returns; throws instead.</returns>
public Task<byte[]> ReadAsync(AbCipTagCreateParams p, uint id, CancellationToken ct) =>
throw new InvalidOperationException("fake read failure");
/// <summary>Disposes the reader.</summary>
public void Dispose() { }
}
}