Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSdkValueBoundaryTests.cs
T

174 lines
10 KiB
C#

using Moq;
using Opc.Ua;
using Opc.Ua.Client;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
[Trait("Category", "Unit")]
public sealed class OpcUaClientSdkValueBoundaryTests
{
private const string Reference = "Plant/Upstream/Value";
private static readonly RawTagEntry[] Tags = [new(Reference, """{"nodeId":"i=2258"}""", false)];
private static (OpcUaClientDriver Driver, Mock<ISession> Session) Connected()
{
var session = new Mock<ISession>();
session.SetupGet(s => s.MessageContext).Returns(ServiceMessageContext.Create(SdkTelemetry.Context));
session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
var driver = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, "sdk2-values");
driver.SetSessionForTest(session.Object);
driver.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags));
return (driver, session);
}
public static TheoryData<Variant, object?> Payloads =>
[
new(Variant.From((sbyte)-3), (sbyte)-3),
new(Variant.From((byte)250), (byte)250),
new(Variant.From((short)-30), (short)-30),
new(Variant.From((ushort)60000), (ushort)60000),
new(Variant.From(-100), -100),
new(Variant.From(uint.MaxValue), uint.MaxValue),
new(Variant.From(long.MinValue), long.MinValue),
new(Variant.From(ulong.MaxValue), ulong.MaxValue),
new(Variant.From(1.5f), 1.5f),
new(Variant.From(1.5d), 1.5d),
new(Variant.From(true), true),
new(Variant.From("text"), "text"),
new(Variant.Null, null),
new(Variant.From(new DateTimeUtc(new DateTime(2026, 11, 10, 1, 2, 3, DateTimeKind.Utc))), new DateTime(2026, 11, 10, 1, 2, 3, DateTimeKind.Utc)),
new(Variant.From(new Uuid(Guid.Parse("db8d57c4-316f-4517-819f-194c3a6db955"))), Guid.Parse("db8d57c4-316f-4517-819f-194c3a6db955")),
new(Variant.From(new byte[] { 1, 2 }.ToByteString()), new byte[] { 1, 2 }),
new(Variant.From(default(ByteString)), null),
new(Variant.From(ByteString.Empty), Array.Empty<byte>()),
new(Variant.From((ArrayOf<bool>)[true, false]), new[] { true, false }),
new(Variant.From((ArrayOf<sbyte>)[-1, 2]), new sbyte[] { -1, 2 }),
new(Variant.From((ArrayOf<byte>)[1, 250]), new byte[] { 1, 250 }),
new(Variant.From((ArrayOf<short>)[1, 2]), new short[] { 1, 2 }),
new(Variant.From((ArrayOf<ushort>)[1, 60000]), new ushort[] { 1, 60000 }),
new(Variant.From((ArrayOf<int>)[-1, 2]), new int[] { -1, 2 }),
new(Variant.From((ArrayOf<long>)[long.MinValue, long.MaxValue]), new long[] { long.MinValue, long.MaxValue }),
new(Variant.From((ArrayOf<ulong>)[0, ulong.MaxValue]), new ulong[] { 0, ulong.MaxValue }),
new(Variant.From((ArrayOf<double>)[1, 2]), new double[] { 1, 2 }),
new(Variant.From((ArrayOf<uint>)[1, 2]), new uint[] { 1, 2 }),
new(Variant.From((ArrayOf<float>)[1, 2]), new float[] { 1, 2 }),
new(Variant.From((ArrayOf<string>)["a", "b"]), new[] { "a", "b" }),
new(Variant.From((ArrayOf<DateTimeUtc>)[new DateTimeUtc(new DateTime(2026, 11, 10, 1, 2, 3, DateTimeKind.Utc))]), new[] { new DateTime(2026, 11, 10, 1, 2, 3, DateTimeKind.Utc) }),
new(Variant.From((ArrayOf<Uuid>)[new Uuid(Guid.Empty)]), new[] { Guid.Empty }),
new(Variant.From((ArrayOf<ByteString>)[new byte[] { 3 }.ToByteString(), ByteString.Empty]), new byte[][] { [3], [] }),
new(Variant.From(default(ArrayOf<short>)), null),
new(Variant.From((ArrayOf<short>)[]), Array.Empty<short>()),
];
[Theory]
[MemberData(nameof(Payloads))]
public async Task Read_and_notification_preserve_CLR_type_status_and_timestamp_rules(Variant upstream, object? expected)
{
var (driver, session) = Connected();
using var _ = driver;
session.Setup(s => s.ReadAsync(It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
It.IsAny<ArrayOf<ReadValueId>>(), It.IsAny<CancellationToken>())).ReturnsAsync(new ReadResponse
{ Results = [new DataValue(upstream, StatusCodes.UncertainLastUsableValue, DateTimeUtc.MinValue, DateTimeUtc.MinValue)] });
var before = DateTime.UtcNow;
var read = (await driver.ReadAsync([Reference], TestContext.Current.CancellationToken)).Single();
AssertPayload(read, expected, before);
DataValueSnapshot? notified = null;
driver.OnDataChange += (_, e) => notified = e.Snapshot;
var handle = driver.AddSubscriptionForTest([Reference]);
driver.DeliverNotificationForTest(handle, Reference,
new DataValue(upstream, StatusCodes.UncertainLastUsableValue, DateTimeUtc.MinValue, DateTimeUtc.MinValue));
AssertPayload(notified.ShouldNotBeNull(), expected, before);
}
private static void AssertPayload(DataValueSnapshot snapshot, object? expected, DateTime before)
{
snapshot.Value.ShouldBe(expected);
snapshot.Value?.GetType().ShouldBe(expected?.GetType());
snapshot.StatusCode.ShouldBe(StatusCodes.UncertainLastUsableValue.Code);
snapshot.SourceTimestampUtc.ShouldBeNull();
snapshot.ServerTimestampUtc.ShouldBeGreaterThanOrEqualTo(before);
snapshot.ServerTimestampUtc.Kind.ShouldBe(DateTimeKind.Utc);
}
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(2)]
public async Task Read_and_notification_detach_upstream_buffers(int shape)
{
var (driver, session) = Connected();
using var _ = driver;
byte[] bytes = [1, 2];
short[] numbers = [3, 4];
// ReadOnlyMemory constructors deliberately alias the backing SDK buffer. Copying the
// test input before the adapter sees it would make the ownership assertion vacuous.
var value = shape switch
{
0 => Variant.From(new ByteString(bytes.AsMemory())),
1 => Variant.From(new ArrayOf<short>(numbers.AsMemory())),
_ => Variant.From((ArrayOf<ByteString>)[new ByteString(bytes.AsMemory())]),
};
object expected = shape switch { 0 => new byte[] { 1, 2 }, 1 => new short[] { 3, 4 }, _ => new byte[][] { [1, 2] } };
session.Setup(s => s.ReadAsync(It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
It.IsAny<ArrayOf<ReadValueId>>(), It.IsAny<CancellationToken>())).ReturnsAsync(new ReadResponse { Results = [new DataValue(value)] });
var read = (await driver.ReadAsync([Reference], TestContext.Current.CancellationToken)).Single();
DataValueSnapshot? notified = null;
driver.OnDataChange += (_, e) => notified = e.Snapshot;
driver.DeliverNotificationForTest(driver.AddSubscriptionForTest([Reference]), Reference, new DataValue(value));
bytes[0] = 99;
numbers[0] = 99;
read.Value.ShouldBe(expected);
notified.ShouldNotBeNull().Value.ShouldBe(expected);
}
[Fact]
public async Task Matrix_read_and_notification_are_refused_per_item_without_materialization()
{
var (driver, session) = Connected();
using var _ = driver;
var sourceTime = new DateTime(2026, 11, 10, 1, 2, 3, DateTimeKind.Utc);
var serverTime = sourceTime.AddSeconds(1);
var matrix = Variant.From(new int[,] { { 1, 2 }, { 3, 4 } }.ToMatrixOf());
session.Setup(s => s.ReadAsync(It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
It.IsAny<ArrayOf<ReadValueId>>(), It.IsAny<CancellationToken>())).ReturnsAsync(new ReadResponse
{ Results = [new DataValue(matrix, StatusCodes.Good, sourceTime, serverTime),
new DataValue(Variant.From(42), StatusCodes.Good, sourceTime, serverTime)] });
var results = await driver.ReadAsync([Reference, Reference], TestContext.Current.CancellationToken);
results[0].Value.ShouldBeNull();
results[0].StatusCode.ShouldBe(StatusCodes.BadTypeMismatch.Code);
results[0].SourceTimestampUtc.ShouldBe(sourceTime);
results[0].ServerTimestampUtc.ShouldBe(serverTime);
results[1].Value.ShouldBe(42);
results[1].StatusCode.ShouldBe(StatusCodes.Good.Code);
DataValueSnapshot? notified = null;
driver.OnDataChange += (_, e) => notified = e.Snapshot;
driver.DeliverNotificationForTest(driver.AddSubscriptionForTest([Reference]), Reference,
new DataValue(matrix, StatusCodes.UncertainLastUsableValue, sourceTime, serverTime));
notified.ShouldNotBeNull().Value.ShouldBeNull();
notified.StatusCode.ShouldBe(StatusCodes.BadTypeMismatch.Code);
notified.SourceTimestampUtc.ShouldBe(sourceTime);
notified.ServerTimestampUtc.ShouldBe(serverTime);
}
[Fact]
public async Task Write_passes_scalar_shaped_arrays_once_and_rejects_unsupported_values_before_IO()
{
var (driver, session) = Connected();
using var _ = driver;
short[] caller = [7, 8];
ArrayOf<WriteValue> sent = default;
session.Setup(s => s.WriteAsync(It.IsAny<RequestHeader>(), It.IsAny<ArrayOf<WriteValue>>(), It.IsAny<CancellationToken>()))
.Callback<RequestHeader, ArrayOf<WriteValue>, CancellationToken>((_, writes, _) => { sent = writes; caller[0] = 99; })
.ReturnsAsync(new WriteResponse { Results = [StatusCodes.Good] });
var result = await driver.WriteAsync([new(Reference, caller), new(Reference, new object()), new(Reference, new int[1, 1])], TestContext.Current.CancellationToken);
result.Select(r => r.StatusCode).ShouldBe([StatusCodes.Good.Code, StatusCodes.BadTypeMismatch.Code, StatusCodes.BadTypeMismatch.Code]);
sent.Count.ShouldBe(1);
sent[0].Value.WrappedValue.TypeInfo.ValueRank.ShouldBe(ValueRanks.OneDimension);
UpstreamValueMapping.ToClr(sent[0].Value.WrappedValue).ShouldBe(new short[] { 7, 8 });
session.Verify(s => s.WriteAsync(It.IsAny<RequestHeader>(), It.IsAny<ArrayOf<WriteValue>>(), It.IsAny<CancellationToken>()), Times.Once);
}
}