using Moq; using Opc.Ua; using Opc.Ua.Client; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests; /// /// Review 2026-10-21 R06:O-5 — the live picker's side-panel AttributesAsync was a stub returning nothing, /// so the operator saw no DataType / array / access information for an upstream variable. It now reads the /// variable's attributes and maps them through the same the runtime /// driver's discovery uses. /// [Trait("Category", "Unit")] public sealed class OpcUaClientBrowseSessionAttributesTests { private const string Speed = "ns=0;s=Speed"; private static Mock SessionReturning(NodeClass nodeClass, NodeId dataType, int valueRank, byte userAccess) { var mock = new Mock(MockBehavior.Loose); mock.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext()); mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); mock.Setup(s => s.ReadAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((RequestHeader _, double _, TimestampsToReturn _, ReadValueIdCollection nodes, CancellationToken _) => new ReadResponse { ResponseHeader = new ResponseHeader(), Results = [.. nodes.Select(n => n.AttributeId switch { Attributes.NodeClass => new DataValue(new Variant((int)nodeClass)), Attributes.DisplayName => new DataValue(new Variant(new LocalizedText("Speed"))), Attributes.DataType => new DataValue(new Variant(dataType)), Attributes.ValueRank => new DataValue(new Variant(valueRank)), Attributes.UserAccessLevel => new DataValue(new Variant(userAccess)), _ => new DataValue(StatusCodes.BadAttributeIdInvalid), })], DiagnosticInfos = [], }); return mock; } private static OpcUaClientBrowseSession NewSession(Mock mock) => new(mock.Object, NamespaceMap.FromTable(new NamespaceTable()), ObjectIds.ObjectsFolder); /// A writable Double array variable reports Float64 / array / Operate. [Fact] public async Task A_variable_reports_its_mapped_data_type_array_shape_and_access() { var mock = SessionReturning(NodeClass.Variable, DataTypeIds.Double, valueRank: 1, userAccess: 0x03); await using var session = NewSession(mock); var attrs = await session.AttributesAsync(Speed, TestContext.Current.CancellationToken); var a = attrs.ShouldHaveSingleItem(); a.Name.ShouldBe("Speed"); a.DriverDataType.ShouldBe("Float64"); a.IsArray.ShouldBeTrue(); a.SecurityClass.ShouldBe("Operate"); a.IsAlarm.ShouldBeFalse(); } /// A read-only scalar Int32 variable reports Int32 / scalar / ViewOnly. [Fact] public async Task A_read_only_scalar_reports_view_only() { var mock = SessionReturning(NodeClass.Variable, DataTypeIds.Int32, valueRank: -1, userAccess: 0x01); await using var session = NewSession(mock); var a = (await session.AttributesAsync(Speed, TestContext.Current.CancellationToken)).ShouldHaveSingleItem(); a.DriverDataType.ShouldBe("Int32"); a.IsArray.ShouldBeFalse(); a.SecurityClass.ShouldBe("ViewOnly"); } /// /// An Object (folder) has no value attributes to show. The positive control is the variable case above, /// through the same mock and read. /// [Fact] public async Task An_object_node_has_no_attributes() { var mock = SessionReturning(NodeClass.Object, DataTypeIds.Int32, valueRank: -1, userAccess: 0x01); await using var session = NewSession(mock); (await session.AttributesAsync(Speed, TestContext.Current.CancellationToken)).ShouldBeEmpty(); } /// An unresolvable node id is refused like ExpandAsync does. [Fact] public async Task An_unresolvable_node_id_throws() { var mock = SessionReturning(NodeClass.Variable, DataTypeIds.Int32, -1, 1); await using var session = NewSession(mock); await Should.ThrowAsync(() => session.AttributesAsync("nsu=urn:not-in-table;s=X", TestContext.Current.CancellationToken)); } private sealed class TrackingCredential : IDisposable { public int Disposed { get; private set; } public void Dispose() => Disposed++; } /// Review m9: the browse session disposes the user certificate it was handed, with itself. [Fact] public async Task The_session_disposes_the_credential_it_owns() { var mock = SessionReturning(NodeClass.Variable, DataTypeIds.Int32, -1, 1); var credential = new TrackingCredential(); var session = new OpcUaClientBrowseSession( mock.Object, NamespaceMap.FromTable(new NamespaceTable()), ObjectIds.ObjectsFolder, credential); credential.Disposed.ShouldBe(0, "positive control: still owned while the session is open"); await session.DisposeAsync(); await session.DisposeAsync(); credential.Disposed.ShouldBe(1); } }