f0d807e7b7
- m1: the form hint now says ProbeTimeoutSeconds can only shorten Test Connect's 10 s budget (passing the configured value as the button's budget is shared code - follow-up). - m2: a superseded init no longer overwrites _options (generation-checked under _probeLock) or the cert-validation handler (handed back and published with the session; detached when not installed; a previous one detached on replace). - m3: an ack none of whose condition ids resolves throws BadNodeIdUnknown + Warning. - m4: an ack with no reply (transport failure / non-caller timeout) degrades. - m5: no session before the gate is the same ServiceResultException(BadNotConnected). - m6: the caller-cancelled-write deviation is documented. - m7: a reply during a published outage advances the stamp but does not write Healthy; the keep-alive recovery ends the outage. - m8: test for a non-caller OperationCanceledException write -> Degraded. - m9: the browse session owns and disposes the user certificate it loaded (OpcUaUserIdentityFactory.LoadCertificate; a key-less cert is disposed before the throw); AdminUI-host certificate path documented. - m10: a subscribe overtaken by a shutdown / re-init removes its half-built subscription instead of registering it (data and alarm). - Sync Dispose drains each gate for 500 ms (was 2 s). Refs #743 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
127 lines
5.5 KiB
C#
127 lines
5.5 KiB
C#
using Moq;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Client;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests;
|
|
|
|
/// <summary>
|
|
/// Review 2026-10-21 R06:O-5 — the live picker's side-panel <c>AttributesAsync</c> 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 <see cref="UpstreamAttributeMapping"/> the runtime
|
|
/// driver's discovery uses.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class OpcUaClientBrowseSessionAttributesTests
|
|
{
|
|
private const string Speed = "ns=0;s=Speed";
|
|
|
|
private static Mock<ISession> SessionReturning(NodeClass nodeClass, NodeId dataType, int valueRank, byte userAccess)
|
|
{
|
|
var mock = new Mock<ISession>(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<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
|
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
|
.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<ISession> mock)
|
|
=> new(mock.Object, NamespaceMap.FromTable(new NamespaceTable()), ObjectIds.ObjectsFolder);
|
|
|
|
/// <summary>A writable Double array variable reports Float64 / array / Operate.</summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>A read-only scalar Int32 variable reports Int32 / scalar / ViewOnly.</summary>
|
|
[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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// An Object (folder) has no value attributes to show. The positive control is the variable case above,
|
|
/// through the same mock and read.
|
|
/// </summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>An unresolvable node id is refused like <c>ExpandAsync</c> does.</summary>
|
|
[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<ArgumentException>(() =>
|
|
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++;
|
|
}
|
|
|
|
/// <summary>Review m9: the browse session disposes the user certificate it was handed, with itself.</summary>
|
|
[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);
|
|
}
|
|
}
|