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 Session) Connected() { var session = new Mock(); 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 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()), new(Variant.From((ArrayOf)[true, false]), new[] { true, false }), new(Variant.From((ArrayOf)[-1, 2]), new sbyte[] { -1, 2 }), new(Variant.From((ArrayOf)[1, 250]), new byte[] { 1, 250 }), new(Variant.From((ArrayOf)[1, 2]), new short[] { 1, 2 }), new(Variant.From((ArrayOf)[1, 60000]), new ushort[] { 1, 60000 }), new(Variant.From((ArrayOf)[-1, 2]), new int[] { -1, 2 }), new(Variant.From((ArrayOf)[long.MinValue, long.MaxValue]), new long[] { long.MinValue, long.MaxValue }), new(Variant.From((ArrayOf)[0, ulong.MaxValue]), new ulong[] { 0, ulong.MaxValue }), new(Variant.From((ArrayOf)[1, 2]), new double[] { 1, 2 }), new(Variant.From((ArrayOf)[1, 2]), new uint[] { 1, 2 }), new(Variant.From((ArrayOf)[1, 2]), new float[] { 1, 2 }), new(Variant.From((ArrayOf)["a", "b"]), new[] { "a", "b" }), new(Variant.From((ArrayOf)[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)[new Uuid(Guid.Empty)]), new[] { Guid.Empty }), new(Variant.From((ArrayOf)[new byte[] { 3 }.ToByteString(), ByteString.Empty]), new byte[][] { [3], [] }), new(Variant.From(default(ArrayOf)), null), new(Variant.From((ArrayOf)[]), Array.Empty()), ]; [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(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())).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(numbers.AsMemory())), _ => Variant.From((ArrayOf)[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(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())).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(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())).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 sent = default; session.Setup(s => s.WriteAsync(It.IsAny(), It.IsAny>(), It.IsAny())) .Callback, 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(), It.IsAny>(), It.IsAny()), Times.Once); } }