3b48e05e9a
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.
243 lines
9.7 KiB
C#
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>
|
|
/// > 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() { }
|
|
}
|
|
}
|