feat(autoid-client): read a device's status without browsing

ReadDeviceAsync browses the children of the device on every call, so
polling the status of many readers costs a Browse and a Read per reader
and cycle; AutoIdReadOptions.StatusOnly only narrows the Read.

Add AutoIdClient.RefreshDeviceAsync(snapshot), which reads DeviceStatus
and, when published, LastScanData, LastScanTimestamp and DeviceLocation
again through the NodeIds the snapshot carries: one Read, no Browse,
no client-side state. A stale snapshot surfaces the status of the Read:
a bad DeviceStatus throws, a bad LastScanData is returned in
LastScanStatus; the caller then reads the device again.

Document the network cost of both calls in the XML docs, docs/AutoID.md
and the client NugetREADME, and add strict-mock tests (no Browse issued)
and end-to-end tests against the hosted AutoID server.
This commit is contained in:
Klaus Landsdorf
2026-10-20 08:38:33 +02:00
committed by Klaus Landsdorf (CEO)
parent ac24123dde
commit 2118c24391
7 changed files with 516 additions and 13 deletions
+27 -4
View File
@@ -402,10 +402,22 @@ reader types. A browse the server rejects throws `ServiceResultException` rather
than yielding an empty list, a rejected method call throws with the server's
status, and every asynchronous member honours cancellation. The status a device
reports for a tag command is returned as data. `AutoIdReadOptions` narrows
`ReadDeviceAsync`: `Default` reads everything the device publishes, `StatusOnly`
reads two variables. Whatever the options, the snapshot carries the NodeIds of
`DeviceStatus`, `LastScanData`, `LastScanTimestamp` and `DeviceLocation` and the
browse names of the methods the device publishes, taken from the same browse:
the Read of `ReadDeviceAsync`: `Default` reads everything the device publishes,
`StatusOnly` reads `DeviceStatus` and `DeviceName`. Every `ReadDeviceAsync` call
costs one Browse of the children of the device (and BrowseNext only when the
server pages the result) and one Read, whatever the options. The snapshot
carries the NodeIds of `DeviceStatus`, `LastScanData`, `LastScanTimestamp` and
`DeviceLocation` and the browse names of the methods the device publishes, taken
from that browse. `RefreshDeviceAsync` takes such a snapshot and reads
`DeviceStatus`, `LastScanData`, `LastScanTimestamp` and `DeviceLocation` again
through these NodeIds: one Read of up to four variables and no Browse. The client
keeps no state, so a poller reads once and then refreshes the snapshot:
| Call | Network cost |
| --- | --- |
| `ReadDeviceAsync(reader)` | One Browse, one Read of every published variable |
| `ReadDeviceAsync(reader, AutoIdReadOptions.StatusOnly)` | One Browse, one Read of two variables |
| `RefreshDeviceAsync(snapshot)` | One Read of up to four variables, no Browse |
```csharp
AutoIdDeviceSnapshot device = await autoId.ReadDeviceAsync(reader, AutoIdReadOptions.StatusOnly);
@@ -413,8 +425,19 @@ if (device.HasMethod(BrowseNames.ScanStart))
{
// monitor device.DeviceStatusId and device.LastScanDataId, then start the scan
}
device = await autoId.RefreshDeviceAsync(device); // poll without browsing
```
`RefreshDeviceAsync` copies every other member of the snapshot, including
`Methods`, and does not check the snapshot against the server. When the snapshot
no longer matches the device (the device or one of its variables was deleted, or
the server assigned other NodeIds after a restart), the status of the Read
surfaces: a bad status for `DeviceStatus`, for example `BadNodeIdUnknown`, is
thrown as `ServiceResultException`, and a bad status for `LastScanData` is
returned in `LastScanStatus` with a null value. The caller then reads the device
again with `ReadDeviceAsync`, which also picks up optional variables and methods
published after the snapshot was taken.
`Observe*Async` methods (scan, access, presence and log entry events) take a
streaming subscription the caller owns; the monitored item lives as long as the
enumeration.
+131 -4
View File
@@ -27,6 +27,7 @@
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
@@ -39,10 +40,14 @@ namespace Opc.Ua.AutoID.Client
/// Reads the state of a reader device in one browse and one read.
/// </summary>
/// <remarks>
/// The browse also yields the NodeIds of the variables to monitor and the methods the device
/// publishes. The optional variables a device does not publish are left at their defaults in the
/// snapshot; a bad status for a variable the device does publish is thrown as a
/// <see cref="ServiceResultException"/>. <see cref="AutoIdReadOptions"/> narrows the read.
/// Every call costs one Browse of the children of the device (and BrowseNext only when the
/// server pages the result) and one Read, whatever the options; <see cref="AutoIdReadOptions"/>
/// only narrows the variables of the Read. The browse
/// yields the NodeIds of the variables to monitor and the methods the device publishes. The
/// optional variables a device does not publish are left at their defaults in the snapshot; a
/// bad status for a variable the device does publish is thrown as a
/// <see cref="ServiceResultException"/>. To poll without browsing, pass the snapshot to
/// <see cref="RefreshDeviceAsync"/>.
/// </remarks>
/// <param name="reader">The reader.</param>
/// <param name="options">What to read; <see cref="AutoIdReadOptions.Default"/> when <see langword="null"/>.</param>
@@ -204,6 +209,128 @@ namespace Opc.Ua.AutoID.Client
};
}
/// <summary>
/// Reads the variables of a snapshot that change at runtime again, through the NodeIds the
/// snapshot carries: <c>DeviceStatus</c> and, when the device publishes them,
/// <c>LastScanData</c>, <c>LastScanTimestamp</c> and <c>DeviceLocation</c>.
/// </summary>
/// <remarks>
/// <para>
/// The call costs exactly one Read of one to four variables and no Browse. The client keeps no
/// state: a caller that polls a device reads it once with <see cref="ReadDeviceAsync"/> and
/// then passes the returned snapshot here on every cycle. All other members of the snapshot,
/// including <see cref="AutoIdDeviceSnapshot.Methods"/> and the NodeIds, are copied unchanged.
/// </para>
/// <para>
/// The snapshot is not validated against the server. When it no longer matches the device
/// (the device or one of its variables was deleted, or the server assigned other NodeIds after
/// a restart), the status of the Read surfaces: a bad status for <c>DeviceStatus</c>, for
/// example <see cref="StatusCodes.BadNodeIdUnknown"/>, is thrown as a
/// <see cref="ServiceResultException"/>, and a bad status for <c>LastScanData</c> is returned
/// in <see cref="AutoIdDeviceSnapshot.LastScanStatus"/> with a null value. A bad
/// <c>LastScanTimestamp</c> or <c>DeviceLocation</c> leaves that member at its default. In
/// each of these cases the caller reads the device again with <see cref="ReadDeviceAsync"/>,
/// which also picks up optional variables and methods the device published after the
/// snapshot was taken.
/// </para>
/// </remarks>
/// <param name="device">A snapshot returned by <see cref="ReadDeviceAsync"/>.</param>
/// <param name="cancellationToken">Cancels the operation.</param>
/// <returns>A copy of <paramref name="device"/> with the current values.</returns>
/// <exception cref="ArgumentNullException"><paramref name="device"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">The snapshot carries no <c>DeviceStatus</c> NodeId.</exception>
/// <exception cref="ServiceResultException">
/// The server rejected the Read, or reading <c>DeviceStatus</c> returned a bad status.
/// </exception>
public async ValueTask<AutoIdDeviceSnapshot> RefreshDeviceAsync(
AutoIdDeviceSnapshot device,
CancellationToken cancellationToken = default)
{
if (device is null)
{
throw new ArgumentNullException(nameof(device));
}
if (device.DeviceStatusId.IsNull)
{
throw new ArgumentException(
"The snapshot carries no DeviceStatus NodeId; read the device with ReadDeviceAsync.",
nameof(device));
}
NodeId[] ids =
[
device.DeviceStatusId,
device.LastScanDataId,
device.LastScanTimestampId,
device.DeviceLocationId
];
var reads = new List<ReadValueId>(ids.Length);
int[] slots = new int[ids.Length];
for (int index = 0; index < ids.Length; index++)
{
slots[index] = -1;
if (!ids[index].IsNull)
{
slots[index] = reads.Count;
reads.Add(new ReadValueId { NodeId = ids[index], AttributeId = Attributes.Value });
}
}
ReadResponse response = await Session.ReadAsync(
requestHeader: null,
maxAge: 0,
timestampsToReturn: TimestampsToReturn.Neither,
nodesToRead: reads.ToArray().ToArrayOf(),
ct: cancellationToken).ConfigureAwait(false);
if (response.Results.Count != reads.Count)
{
throw ServiceResultException.Create(
StatusCodes.BadUnexpectedError,
"The server returned {0} read results for {1} requested variables.",
response.Results.Count,
reads.Count);
}
DataValue Value(int index)
{
return slots[index] < 0
? new DataValue(Variant.Null, StatusCodes.BadNotFound)
: response.Results[slots[index]];
}
DataValue status = Value(0);
if (StatusCode.IsBad(status.StatusCode))
{
throw ServiceResultException.Create(
status.StatusCode,
"Reading {0} of {1} failed.",
BrowseNames.DeviceStatus,
device.NodeId);
}
DataValue lastData = Value(1);
DataValue lastTimestamp = Value(2);
DataValue deviceLocation = Value(3);
Location? locationValue = null;
#pragma warning disable CS8600 // The generic structure accessor declares its out value as possibly null.
if (StatusCode.IsNotBad(deviceLocation.StatusCode) &&
deviceLocation.WrappedValue.TryGetValue(out Location decodedLocation, Session.MessageContext) &&
decodedLocation is not null)
#pragma warning restore CS8600
{
locationValue = decodedLocation;
}
return device with
{
DeviceStatus = status.WrappedValue.TryGetValue(out DeviceStatusEnumeration deviceStatus)
? deviceStatus
: default,
LastScanData = StatusCode.IsBad(lastData.StatusCode) ? Variant.Null : lastData.WrappedValue,
LastScanStatus = lastData.StatusCode,
LastScanTimestamp = StatusCode.IsNotBad(lastTimestamp.StatusCode) &&
lastTimestamp.WrappedValue.TryGetValue(out DateTimeUtc timestamp)
? timestamp
: default,
DeviceLocation = locationValue
};
}
private static string TextOf(DataValue value)
{
return value.WrappedValue.TryGetValue(out string text) ? text ?? string.Empty : string.Empty;
@@ -41,7 +41,8 @@ namespace Opc.Ua.AutoID.Client
/// The NodeIds of the variables that change at runtime and the methods the device publishes
/// come from the browse that precedes the read, whatever the options, so a client can monitor
/// the device and knows which optional methods (OPC 30010 6.1, 6.5.3, 6.6.3) it may call
/// without browsing again.
/// without browsing again. <see cref="AutoIdClient.RefreshDeviceAsync"/> reads these
/// variables again through the NodeIds, in one Read and without a Browse.
/// </remarks>
public sealed record AutoIdDeviceSnapshot
{
@@ -36,7 +36,8 @@ namespace Opc.Ua.AutoID.Client
/// The defaults read the mandatory variables and everything optional the device publishes: the
/// nameplate, the optional device properties and the last scan. <see cref="StatusOnly"/>
/// limits the value read to <c>DeviceStatus</c> and <c>DeviceName</c>; each call still
/// browses the reader to discover its published variables and methods.
/// browses the reader to discover its published variables and methods. To poll a device
/// without browsing, pass the snapshot to <see cref="AutoIdClient.RefreshDeviceAsync"/>.
/// </remarks>
public sealed record AutoIdReadOptions
{
+5 -3
View File
@@ -29,9 +29,11 @@ await foreach (AutoIdScanEventTypeRecord e in
`LockTagAsync`, `SetTagPasswordAsync`; the status a device reports for a tag is
returned, not thrown.
- RTLS `GetLocationAsync` and `GetSupportedLocationTypesAsync`.
- `ReadDeviceAsync` with `AutoIdReadOptions` (`Default`, `StatusOnly`); the
snapshot also carries the NodeIds of the variables to monitor and the methods
the device publishes.
- `ReadDeviceAsync` with `AutoIdReadOptions` (`Default`, `StatusOnly`), one
Browse and one Read per call; the snapshot also carries the NodeIds of the
variables to monitor and the methods the device publishes.
`RefreshDeviceAsync` re-reads the status, last scan and location of a snapshot
in one Read without browsing.
- Asynchronous streams of scan, access, presence and logbook entry events.
A browse the server rejects throws `ServiceResultException` instead of yielding an
@@ -711,6 +711,61 @@ namespace Opc.Ua.AutoID.Tests
await WaitForStatusAsync(AutoIdHostFixture.OpticalName, DeviceStatusEnumeration.Idle).ConfigureAwait(false);
}
[Test]
public async Task ARefreshedSnapshotCarriesTheCurrentStatusAsync()
{
AutoIdReaderEntry reader = await ReaderAsync(AutoIdHostFixture.OpticalName).ConfigureAwait(false);
AutoIdReader hosted = m_host!.Runtime.Readers.ToArray()!
.Single(r => r.Name.Name == AutoIdHostFixture.OpticalName);
AutoIdDeviceSnapshot idle = await m_client!.ReadDeviceAsync(reader, AutoIdReadOptions.StatusOnly)
.ConfigureAwait(false);
await hosted.SetFaultedAsync(true).ConfigureAwait(false);
try
{
AutoIdDeviceSnapshot faulty = await m_client.RefreshDeviceAsync(idle).ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(idle.DeviceStatus, Is.EqualTo(DeviceStatusEnumeration.Idle));
Assert.That(faulty.DeviceStatus, Is.EqualTo(DeviceStatusEnumeration.Error));
Assert.That(faulty.LastScanStatus, Is.EqualTo(StatusCodes.UncertainInitialValue));
Assert.That(faulty.Methods, Is.EqualTo(idle.Methods));
});
}
finally
{
await hosted.SetFaultedAsync(false).ConfigureAwait(false);
}
await WaitForStatusAsync(AutoIdHostFixture.OpticalName, DeviceStatusEnumeration.Idle).ConfigureAwait(false);
}
[Test]
public async Task ARefreshOfAStaleSnapshotSurfacesTheStatusOfTheReadAsync()
{
AutoIdReaderEntry reader = await ReaderAsync(AutoIdHostFixture.RfidName).ConfigureAwait(false);
AutoIdDeviceSnapshot current = await m_client!.ReadDeviceAsync(reader, AutoIdReadOptions.StatusOnly)
.ConfigureAwait(false);
Assert.That(current.LastScanDataId.IsNull, Is.False);
// NodeIds the server does not know, as after the variables were deleted.
var unknown = new NodeId("NoSuchVariable", current.DeviceStatusId.NamespaceIndex);
AutoIdDeviceSnapshot staleScan = await m_client
.RefreshDeviceAsync(current with { LastScanDataId = unknown })
.ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(staleScan.DeviceStatus, Is.EqualTo(current.DeviceStatus));
Assert.That(staleScan.LastScanStatus, Is.EqualTo(StatusCodes.BadNodeIdUnknown));
Assert.That(staleScan.LastScanData.IsNull, Is.True);
});
await Assert.ThatAsync(
async () => await m_client.RefreshDeviceAsync(current with { DeviceStatusId = unknown })
.ConfigureAwait(false),
Throws.TypeOf<ServiceResultException>().With.Property("StatusCode")
.EqualTo(StatusCodes.BadNodeIdUnknown)).ConfigureAwait(false);
}
[Test]
public async Task TheServerPublishesTheFacetsOfTheReadersItHostsAsync()
{
@@ -0,0 +1,294 @@
/* ========================================================================
* 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.Threading;
using System.Threading.Tasks;
using Moq;
using NUnit.Framework;
using Opc.Ua.AutoID.Client;
using Opc.Ua.Client;
using Opc.Ua.Tests;
namespace Opc.Ua.AutoID.Tests
{
/// <summary>
/// Pins the service calls of <see cref="AutoIdClient.RefreshDeviceAsync"/> on a strict session
/// mock: one Read of the NodeIds the snapshot carries and no Browse.
/// </summary>
[TestFixture]
[Category("AutoID")]
public sealed class AutoIdRefreshDeviceTests
{
[Test]
public async Task OneReadOfTheSnapshotNodeIdsAndNoBrowseAsync()
{
(Mock<ISession> session, AutoIdClient client) = Create();
var timestamp = new DateTimeUtc(new DateTime(2026, 10, 19, 8, 0, 0, DateTimeKind.Utc));
var requested = new List<NodeId>();
SetupRead(
session,
requested,
new DataValue(new Variant((int)DeviceStatusEnumeration.Scanning)),
new DataValue(new Variant("tag-1")),
new DataValue(new Variant(timestamp)),
new DataValue(Variant.Null));
AutoIdDeviceSnapshot snapshot = Snapshot(withOptionalVariables: true);
AutoIdDeviceSnapshot refreshed = await client.RefreshDeviceAsync(snapshot).ConfigureAwait(false);
NodeId[] expected = [s_status, s_lastData, s_lastTimestamp, s_location];
Assert.Multiple(() =>
{
Assert.That(requested, Is.EqualTo(expected));
Assert.That(refreshed.DeviceStatus, Is.EqualTo(DeviceStatusEnumeration.Scanning));
Assert.That(refreshed.LastScanData.TryGetValue(out string text) ? text : null, Is.EqualTo("tag-1"));
Assert.That(refreshed.LastScanStatus, Is.EqualTo(StatusCodes.Good));
Assert.That(refreshed.LastScanTimestamp, Is.EqualTo(timestamp));
Assert.That(refreshed.DeviceLocation, Is.Null);
Assert.That(refreshed.DeviceName, Is.EqualTo(snapshot.DeviceName));
Assert.That(refreshed.Methods, Is.EqualTo(snapshot.Methods));
Assert.That(refreshed.LastScanDataId, Is.EqualTo(s_lastData));
Assert.That(
snapshot.DeviceStatus,
Is.EqualTo(DeviceStatusEnumeration.Idle),
"the input is not changed");
});
VerifyOneReadOnly(session);
}
[Test]
public async Task ADeviceWithoutOptionalVariablesIsRefreshedWithOneVariableAsync()
{
(Mock<ISession> session, AutoIdClient client) = Create();
var requested = new List<NodeId>();
SetupRead(session, requested, new DataValue(new Variant((int)DeviceStatusEnumeration.Error)));
AutoIdDeviceSnapshot refreshed = await client.RefreshDeviceAsync(Snapshot(withOptionalVariables: false))
.ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(requested, Has.Count.EqualTo(1).And.Contains(s_status));
Assert.That(refreshed.DeviceStatus, Is.EqualTo(DeviceStatusEnumeration.Error));
Assert.That(refreshed.LastScanStatus, Is.EqualTo(StatusCodes.BadNotFound));
Assert.That(refreshed.LastScanData.IsNull, Is.True);
});
VerifyOneReadOnly(session);
}
[Test]
public async Task AStaleDeviceStatusNodeIdThrowsTheStatusOfTheReadAsync()
{
(Mock<ISession> session, AutoIdClient client) = Create();
SetupRead(
session,
[],
DataValue.FromStatusCode(StatusCodes.BadNodeIdUnknown),
DataValue.FromStatusCode(StatusCodes.BadNodeIdUnknown),
DataValue.FromStatusCode(StatusCodes.BadNodeIdUnknown),
DataValue.FromStatusCode(StatusCodes.BadNodeIdUnknown));
await Assert.ThatAsync(
async () => await client.RefreshDeviceAsync(Snapshot(withOptionalVariables: true))
.ConfigureAwait(false),
Throws.TypeOf<ServiceResultException>().With.Property("StatusCode")
.EqualTo(StatusCodes.BadNodeIdUnknown)).ConfigureAwait(false);
VerifyOneReadOnly(session);
}
[Test]
public async Task AStaleOptionalVariableSurfacesItsStatusWithoutThrowingAsync()
{
(Mock<ISession> session, AutoIdClient client) = Create();
SetupRead(
session,
[],
new DataValue(new Variant((int)DeviceStatusEnumeration.Idle)),
DataValue.FromStatusCode(StatusCodes.BadNodeIdUnknown),
DataValue.FromStatusCode(StatusCodes.BadNodeIdUnknown),
DataValue.FromStatusCode(StatusCodes.BadUserAccessDenied));
AutoIdDeviceSnapshot snapshot = Snapshot(withOptionalVariables: true) with
{
LastScanData = new Variant("old"),
LastScanTimestamp = new DateTimeUtc(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)),
DeviceLocation = new Location()
};
AutoIdDeviceSnapshot refreshed = await client.RefreshDeviceAsync(snapshot).ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(refreshed.DeviceStatus, Is.EqualTo(DeviceStatusEnumeration.Idle));
Assert.That(refreshed.LastScanStatus, Is.EqualTo(StatusCodes.BadNodeIdUnknown));
Assert.That(refreshed.LastScanData.IsNull, Is.True);
Assert.That(refreshed.LastScanTimestamp, Is.Default);
Assert.That(refreshed.DeviceLocation, Is.Null);
});
VerifyOneReadOnly(session);
}
[Test]
public async Task AResultCountMismatchIsRejectedAsync()
{
(Mock<ISession> session, AutoIdClient client) = Create();
SetupRead(session, [], new DataValue(new Variant((int)DeviceStatusEnumeration.Idle)));
await Assert.ThatAsync(
async () => await client.RefreshDeviceAsync(Snapshot(withOptionalVariables: true))
.ConfigureAwait(false),
Throws.TypeOf<ServiceResultException>().With.Property("StatusCode")
.EqualTo(StatusCodes.BadUnexpectedError)).ConfigureAwait(false);
VerifyOneReadOnly(session);
}
[Test]
public async Task InvalidSnapshotsAreRejectedWithoutAServiceCallAsync()
{
(Mock<ISession> session, AutoIdClient client) = Create();
await Assert.MultipleAsync(async () =>
{
await Assert.ThrowsAsync<ArgumentNullException>(
async () => await client.RefreshDeviceAsync(null!).ConfigureAwait(false)).ConfigureAwait(false);
await Assert.ThrowsAsync<ArgumentException>(
async () => await client.RefreshDeviceAsync(new AutoIdDeviceSnapshot { NodeId = s_device })
.ConfigureAwait(false)).ConfigureAwait(false);
}).ConfigureAwait(false);
session.VerifyGet(value => value.MessageContext, Times.Once);
session.VerifyNoOtherCalls();
}
[Test]
public async Task CancellationReachesTheReadAsync()
{
(Mock<ISession> session, AutoIdClient client) = Create();
using var cancellation = new CancellationTokenSource();
CancellationToken observed = default;
session.Setup(
value => value.ReadAsync(
It.IsAny<RequestHeader?>(),
It.IsAny<double>(),
It.IsAny<TimestampsToReturn>(),
It.IsAny<ArrayOf<ReadValueId>>(),
It.IsAny<CancellationToken>()))
.Returns(
(RequestHeader? _,
double _,
TimestampsToReturn _,
ArrayOf<ReadValueId> _,
CancellationToken token) =>
{
observed = token;
return new ValueTask<ReadResponse>(
Task.FromCanceled<ReadResponse>(new CancellationToken(true)));
});
await cancellation.CancelAsync().ConfigureAwait(false);
await Assert.ThatAsync(
async () => await client.RefreshDeviceAsync(Snapshot(withOptionalVariables: false), cancellation.Token)
.ConfigureAwait(false),
Throws.InstanceOf<OperationCanceledException>()).ConfigureAwait(false);
Assert.That(observed, Is.EqualTo(cancellation.Token));
}
private static (Mock<ISession> Session, AutoIdClient Client) Create()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
var context = ServiceMessageContext.Create(telemetry);
var session = new Mock<ISession>(MockBehavior.Strict);
session.SetupGet(value => value.MessageContext).Returns(context);
return (session, new AutoIdClient(session.Object, telemetry));
}
private static AutoIdDeviceSnapshot Snapshot(bool withOptionalVariables)
{
return new AutoIdDeviceSnapshot
{
NodeId = s_device,
DeviceStatusId = s_status,
LastScanDataId = withOptionalVariables ? s_lastData : NodeId.Null,
LastScanTimestampId = withOptionalVariables ? s_lastTimestamp : NodeId.Null,
DeviceLocationId = withOptionalVariables ? s_location : NodeId.Null,
Methods = [new QualifiedName(BrowseNames.Scan, 2)],
DeviceName = "Reader",
DeviceStatus = DeviceStatusEnumeration.Idle,
LastScanStatus = withOptionalVariables ? StatusCodes.UncertainInitialValue : StatusCodes.BadNotFound
};
}
private static void SetupRead(Mock<ISession> session, List<NodeId> requested, params DataValue[] results)
{
session.Setup(
value => value.ReadAsync(
It.IsAny<RequestHeader?>(),
0,
TimestampsToReturn.Neither,
It.IsAny<ArrayOf<ReadValueId>>(),
It.IsAny<CancellationToken>()))
.Returns(
(RequestHeader? _,
double _,
TimestampsToReturn _,
ArrayOf<ReadValueId> nodes,
CancellationToken _) =>
{
foreach (ReadValueId node in nodes)
{
Assert.That(node.AttributeId, Is.EqualTo(Attributes.Value));
requested.Add(node.NodeId);
}
return new ValueTask<ReadResponse>(
new ReadResponse { ResponseHeader = new ResponseHeader(), Results = results.ToArrayOf() });
});
}
private static void VerifyOneReadOnly(Mock<ISession> session)
{
session.Verify(
value => value.ReadAsync(
It.IsAny<RequestHeader?>(),
It.IsAny<double>(),
It.IsAny<TimestampsToReturn>(),
It.IsAny<ArrayOf<ReadValueId>>(),
It.IsAny<CancellationToken>()),
Times.Once);
session.VerifyGet(value => value.MessageContext, Times.AtLeastOnce);
// A strict mock already fails any Browse; this also rules out every other service call.
session.VerifyNoOtherCalls();
}
private static readonly NodeId s_device = new("Reader", 2);
private static readonly NodeId s_status = new("Reader.DeviceStatus", 2);
private static readonly NodeId s_lastData = new("Reader.LastScanData", 2);
private static readonly NodeId s_lastTimestamp = new("Reader.LastScanTimestamp", 2);
private static readonly NodeId s_location = new("Reader.DeviceLocation", 2);
}
}