174 lines
10 KiB
C#
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);
|
|
}
|
|
}
|