/* ======================================================================== * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. * * OPC Foundation MIT License 1.00 * * Permission is hereby granted, free of charge, to any person * obtaining a copy of this software and associated documentation * files (the "Software"), to deal in the Software without * restriction, including without limitation the rights to use, * copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the * Software is furnished to do so, subject to the following * conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * OTHER DEALINGS IN THE SOFTWARE. * * The complete license agreement can be found here: * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ using System; using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Moq; using NUnit.Framework; using Opc.Ua.Client; using Opc.Ua.Client.Subscriptions; using Opc.Ua.Client.Subscriptions.Streaming; using Opc.Ua.Vision; using Opc.Ua.Vision.Client; namespace Opc.Ua.Vision.Tests { /// /// Fills the gaps flagged by the vision-coverage baseline: exercises /// along the /// happy path (48 lines that no other test entered), the /// record surface, and the /// three Observe entry points that were only /// covered on their argument-guard path. /// [TestFixture] [Category("Vision")] public sealed class VisionClientCoverageTests { [Test] public async Task ReadExtrinsicCalibrationReturnsSnapshotWithMountFramesAndTransform() { var harness = new VisionSessionHarness(); harness.AddSensor(ObjectTypes.ImageSensorType); var pose = new VisionPose3DDataType { FrameId = "flange", Position = new double[] { 0.10, 0.20, 0.30 }, Orientation = new double[] { 0.0, 0.0, 0.0, 1.0 } }; harness.AddValueChild(harness.ExtrinsicCalibrationNodeId, BrowseNames.CalibrationId, new(2700u, 3), "ex-1"); harness.AddValueChild(harness.ExtrinsicCalibrationNodeId, BrowseNames.PerformedAt, new(2701u, 3), new DateTimeUtc(new DateTime(2024, 6, 1, 12, 0, 0, DateTimeKind.Utc))); harness.AddValueChild(harness.ExtrinsicCalibrationNodeId, BrowseNames.Valid, new(2702u, 3), true); harness.AddValueChild(harness.ExtrinsicCalibrationNodeId, BrowseNames.ResidualError, new(2703u, 3), 0.08); harness.AddValueChild(harness.ExtrinsicCalibrationNodeId, BrowseNames.Method, new(2704u, 3), "HandEye-Tsai"); harness.AddValueChild(harness.ExtrinsicCalibrationNodeId, BrowseNames.Mount, new(2705u, 3), (int)VisionCalibrationMountEnum.EyeInHand); harness.AddValueChild(harness.ExtrinsicCalibrationNodeId, BrowseNames.SourceFrame, new(2706u, 3), harness.SensorNodeId); harness.AddValueChild(harness.ExtrinsicCalibrationNodeId, BrowseNames.TargetFrame, new(2707u, 3), harness.FrameNodeId); harness.AddValueChild(harness.ExtrinsicCalibrationNodeId, BrowseNames.Transform, new(2708u, 3), Variant.FromStructure(pose)); VisionSensorClient sensor = harness.Client.Sensor(harness.SensorNodeId); VisionExtrinsicCalibrationSnapshot snapshot = await sensor .ReadExtrinsicCalibrationAsync(harness.ExtrinsicCalibrationNodeId) .ConfigureAwait(false); Assert.Multiple(() => { Assert.That(snapshot.NodeId, Is.EqualTo(harness.ExtrinsicCalibrationNodeId)); Assert.That(snapshot.CalibrationId, Is.EqualTo("ex-1")); Assert.That(snapshot.Valid, Is.True); Assert.That(snapshot.ResidualError, Is.EqualTo(0.08)); Assert.That(snapshot.Method, Is.EqualTo("HandEye-Tsai")); Assert.That(snapshot.Mount, Is.EqualTo(VisionCalibrationMountEnum.EyeInHand)); Assert.That(snapshot.SourceFrameId, Is.EqualTo(harness.SensorNodeId)); Assert.That(snapshot.TargetFrameId, Is.EqualTo(harness.FrameNodeId)); Assert.That(snapshot.Transform, Is.Not.Null); Assert.That(snapshot.Transform!.FrameId, Is.EqualTo("flange")); }); } [Test] public async Task ReadExtrinsicCalibrationLeavesOptionalMembersDefaultWhenTheyResolveToNull() { var harness = new VisionSessionHarness(); harness.AddSensor(ObjectTypes.ImageSensorType); harness.AddValueChild(harness.ExtrinsicCalibrationNodeId, BrowseNames.CalibrationId, new(2710u, 3), "ex-min"); // No PerformedAt/Valid/ResidualError/Method/Mount/Frames/Transform bindings. // BrowsePathResults for those names must come back BadNoMatch, so the ArrayOf // slots are Null and each TakeXxx helper skips the value read entirely. VisionSensorClient sensor = harness.Client.Sensor(harness.SensorNodeId); VisionExtrinsicCalibrationSnapshot snapshot = await sensor .ReadExtrinsicCalibrationAsync(harness.ExtrinsicCalibrationNodeId) .ConfigureAwait(false); Assert.Multiple(() => { Assert.That(snapshot.CalibrationId, Is.EqualTo("ex-min"), "the one bound member must survive the missing-optional path"); Assert.That(snapshot.Valid, Is.False, "TakeBool must return the struct default when the member is absent, not throw"); Assert.That(snapshot.ResidualError, Is.EqualTo(0.0)); Assert.That(snapshot.Method, Is.Null); Assert.That(snapshot.Mount, Is.EqualTo(default(VisionCalibrationMountEnum))); Assert.That(snapshot.Transform, Is.Null); // SourceFrameId / TargetFrameId are INullable structs — assert .IsNull rather than // Is.Null (see the coverage instructions). Assert.That(snapshot.SourceFrameId.IsNull, Is.True); Assert.That(snapshot.TargetFrameId.IsNull, Is.True); }); } [Test] public void VisionExtrinsicCalibrationSnapshotRecordEqualityIsStructural() { var nodeId = new NodeId(9001u, 3); var pose = new VisionPose3DDataType { FrameId = "tcp" }; var left = new VisionExtrinsicCalibrationSnapshot { NodeId = nodeId, CalibrationId = "cal", PerformedAt = new DateTimeUtc(new DateTime(2024, 2, 3, 4, 5, 6, DateTimeKind.Utc)), Valid = true, ResidualError = 0.42, Method = "Tsai", Mount = VisionCalibrationMountEnum.EyeToHand, SourceFrameId = new NodeId(1u, 3), TargetFrameId = new NodeId(2u, 3), Transform = pose }; var right = left with { }; Assert.Multiple(() => { Assert.That(right, Is.EqualTo(left), "sealed record `with { }` must yield a structurally equal snapshot"); Assert.That(right.GetHashCode(), Is.EqualTo(left.GetHashCode())); Assert.That(right, Is.Not.SameAs(left)); Assert.That(right.NodeId, Is.EqualTo(nodeId)); Assert.That(right.Transform, Is.SameAs(pose)); Assert.That(right.SourceFrameId, Is.EqualTo(new NodeId(1u, 3))); Assert.That(right.TargetFrameId, Is.EqualTo(new NodeId(2u, 3))); }); } [Test] public void ObserveDetectionsAsyncRejectsNullStreamingWithArgumentNullException() { var harness = new VisionSessionHarness(); VisionResultReader reader = harness.Client.Result(harness.ResultNodeId); Assert.That(() => reader.ObserveDetectionsAsync(null!), Throws.InstanceOf() .With.Property("ParamName").EqualTo("streaming")); } [Test] public void ObserveInspectionAsyncRejectsNullStreamingWithArgumentNullException() { var harness = new VisionSessionHarness(); VisionResultReader reader = harness.Client.Result(harness.ResultNodeId); Assert.That(() => reader.ObserveInspectionAsync(null!), Throws.InstanceOf() .With.Property("ParamName").EqualTo("streaming")); } [Test] public void ObserveDetectionsAsyncThrowsBadNotFoundWhenResultDoesNotExposeDetections() { var harness = new VisionSessionHarness(); var streaming = new Mock().Object; VisionResultReader reader = harness.Client.Result(harness.ResultNodeId); var ex = Assert.ThrowsAsync(async () => { await foreach (var _ in reader.ObserveDetectionsAsync(streaming) .ConfigureAwait(false)) { } }); Assert.That((uint)ex!.StatusCode, Is.EqualTo(StatusCodes.BadNotFound)); } [Test] public void ObserveInspectionAsyncThrowsBadNotFoundWhenResultDoesNotExposeCharacteristics() { var harness = new VisionSessionHarness(); var streaming = new Mock().Object; VisionResultReader reader = harness.Client.Result(harness.ResultNodeId); var ex = Assert.ThrowsAsync(async () => { await foreach (var _ in reader.ObserveInspectionAsync(streaming) .ConfigureAwait(false)) { } }); Assert.That((uint)ex!.StatusCode, Is.EqualTo(StatusCodes.BadNotFound)); } [Test] public void ObserveSegmentationAsyncThrowsBadNotFoundWhenResultDoesNotExposeMask() { var harness = new VisionSessionHarness(); var streaming = new Mock().Object; VisionResultReader reader = harness.Client.Result(harness.ResultNodeId); var ex = Assert.ThrowsAsync(async () => { await foreach (var _ in reader.ObserveSegmentationAsync(streaming) .ConfigureAwait(false)) { } }); Assert.That((uint)ex!.StatusCode, Is.EqualTo(StatusCodes.BadNotFound)); } [Test] public async Task ObserveDetectionsAsyncCompletesGracefullyWhenSubscribeCompletesWithNoNotifications() { var harness = new VisionSessionHarness(); harness.AddValueChild(harness.ResultNodeId, BrowseNames.Detections, new(3210u, 3), new Variant(ArrayOf.Empty)); IReadOnlyList? monitored = null; var streaming = new Mock(); streaming .Setup(s => s.SubscribeDataChangesAsync( It.IsAny>(), It.IsAny(), It.IsAny())) .Returns, Opc.Ua.Client.Subscriptions.MonitoredItems.MonitoredItemOptions?, CancellationToken>((ids, _, ct) => { monitored = ids; return EmptyStreamAsync(ct); }); VisionResultReader reader = harness.Client.Result(harness.ResultNodeId); int received = 0; await foreach (var _ in reader.ObserveDetectionsAsync(streaming.Object) .ConfigureAwait(false)) { received++; } Assert.Multiple(() => { Assert.That(received, Is.EqualTo(0), "an empty upstream stream must yield zero snapshots — the reader " + "may not fabricate data"); Assert.That(monitored, Is.Not.Null); Assert.That(monitored!.Count, Is.EqualTo(1)); Assert.That(monitored[0], Is.EqualTo(new NodeId(3210u, 3)), "the observe iterator must have resolved the Detections child NodeId"); }); } [Test] public async Task ObserveInspectionAsyncCompletesGracefullyWhenSubscribeCompletesWithNoNotifications() { var harness = new VisionSessionHarness(); harness.AddValueChild(harness.ResultNodeId, BrowseNames.Characteristics, new(3220u, 3), new Variant(ArrayOf.Empty)); IReadOnlyList? monitored = null; var streaming = new Mock(); streaming .Setup(s => s.SubscribeDataChangesAsync( It.IsAny>(), It.IsAny(), It.IsAny())) .Returns, Opc.Ua.Client.Subscriptions.MonitoredItems.MonitoredItemOptions?, CancellationToken>((ids, _, ct) => { monitored = ids; return EmptyStreamAsync(ct); }); VisionResultReader reader = harness.Client.Result(harness.ResultNodeId); int received = 0; await foreach (var _ in reader.ObserveInspectionAsync(streaming.Object) .ConfigureAwait(false)) { received++; } Assert.That(received, Is.EqualTo(0)); Assert.That(monitored, Is.Not.Null); Assert.That(monitored![0], Is.EqualTo(new NodeId(3220u, 3))); } [Test] public async Task ObserveSegmentationAsyncCompletesGracefullyWhenSubscribeCompletesWithNoNotifications() { var harness = new VisionSessionHarness(); harness.AddValueChild(harness.ResultNodeId, BrowseNames.Mask, new(3230u, 3), new Variant(ByteString.Empty)); IReadOnlyList? monitored = null; var streaming = new Mock(); streaming .Setup(s => s.SubscribeDataChangesAsync( It.IsAny>(), It.IsAny(), It.IsAny())) .Returns, Opc.Ua.Client.Subscriptions.MonitoredItems.MonitoredItemOptions?, CancellationToken>((ids, _, ct) => { monitored = ids; return EmptyStreamAsync(ct); }); VisionResultReader reader = harness.Client.Result(harness.ResultNodeId); int received = 0; await foreach (var _ in reader.ObserveSegmentationAsync(streaming.Object) .ConfigureAwait(false)) { received++; } Assert.That(received, Is.EqualTo(0)); Assert.That(monitored, Is.Not.Null); Assert.That(monitored![0], Is.EqualTo(new NodeId(3230u, 3))); } [Test] public async Task GetStreamEndpointAsyncReturnsSessionFromServer() { var harness = new VisionSessionHarness(); var session = new VisionStreamSessionDataType { SessionToken = new ByteString(new byte[] { 42, 43 }), Uri = "rtsp://cam.local/live", Protocol = VisionStreamProtocolEnum.Rtsp, ExpiresAt = new DateTimeUtc(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)) }; harness.ConfigureCall(StatusCodes.Good, Variant.FromStructure(session), new Variant(harness.StreamEndpointNodeId)); VisionMediaClient media = harness.Client.Media(harness.MediaNodeId); VisionStreamSessionDataType result = await media.GetStreamEndpointAsync( harness.StreamEndpointNodeId, "default", VisionStreamProtocolEnum.Rtsp).ConfigureAwait(false); Assert.Multiple(() => { Assert.That(result, Is.Not.Null); Assert.That(result.Uri, Is.EqualTo("rtsp://cam.local/live")); Assert.That(result.Protocol, Is.EqualTo(VisionStreamProtocolEnum.Rtsp)); Assert.That(result.SessionToken.ToArray(), Is.EqualTo(new byte[] { 42, 43 }), "the token bytes must survive the round-trip through the proxy layer"); }); } [Test] public async Task ReadLatestClipMetadataAsyncReturnsMetadataWhenPresent() { var harness = new VisionSessionHarness(); var metadata = new VisionImageReferenceDataType { Uri = "opc.ua://server/clips/latest.json", DigestAlgorithm = "SHA-256", PixelFormat = "Mono8", Width = 640, Height = 480 }; harness.AddValueChild(harness.ClipEndpointNodeId, BrowseNames.LatestClipMetadata, new(2401u, 3), Variant.FromStructure(metadata)); VisionMediaClient media = harness.Client.Media(harness.MediaNodeId); VisionImageReferenceDataType? result = await media .ReadLatestClipMetadataAsync(harness.ClipEndpointNodeId) .ConfigureAwait(false); Assert.That(result, Is.Not.Null); Assert.That(result!.Uri, Is.EqualTo("opc.ua://server/clips/latest.json")); Assert.That(result.PixelFormat, Is.EqualTo("Mono8")); Assert.That(result.Width, Is.EqualTo(640u)); Assert.That(result.Height, Is.EqualTo(480u)); } [Test] public async Task ReadLatestClipMetadataAsyncReturnsNullWhenAbsent() { var harness = new VisionSessionHarness(); // No metadata node bound — TryReadStructureAsync short-circuits on a null NodeId. VisionMediaClient media = harness.Client.Media(harness.MediaNodeId); VisionImageReferenceDataType? result = await media .ReadLatestClipMetadataAsync(harness.ClipEndpointNodeId) .ConfigureAwait(false); Assert.That(result, Is.Null, "when the LatestClipMetadata child is absent the reader must return null " + "rather than fabricate an empty descriptor"); } private static async IAsyncEnumerable EmptyStreamAsync( [EnumeratorCancellation] CancellationToken cancellationToken) { await Task.CompletedTask.ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); yield break; } [Test] public void VisionClientFactoryCreateAsyncInvokesTheInjectedSessionFactoryAndPropagatesCancellation() { using var cts = new CancellationTokenSource(); cts.Cancel(); var telemetry = new Mock().Object; CancellationToken observedToken = default; int invocations = 0; Func> sessionFactory = ct => { Interlocked.Increment(ref invocations); observedToken = ct; ct.ThrowIfCancellationRequested(); return Task.FromResult(null!); }; var factory = new VisionClientFactory(sessionFactory, telemetry); Assert.That( async () => await factory.CreateAsync(cts.Token).ConfigureAwait(false), Throws.InstanceOf()); Assert.Multiple(() => { Assert.That(invocations, Is.EqualTo(1), "CreateAsync must forward straight to the session factory once"); Assert.That(observedToken, Is.EqualTo(cts.Token), "the exact caller token must be threaded through — not default"); }); } [Test] public void VisionClientFactoryConstructorRejectsNullSessionFactory() { var telemetry = new Mock().Object; Assert.That(() => new VisionClientFactory(null!, telemetry), Throws.InstanceOf() .With.Property("ParamName").EqualTo("sessionFactory")); } [Test] public void VisionClientFactoryConstructorRejectsNullTelemetry() { Func> sessionFactory = _ => Task.FromResult(null!); Assert.That(() => new VisionClientFactory(sessionFactory, null!), Throws.InstanceOf() .With.Property("ParamName").EqualTo("telemetry")); } } }