diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs index 54465e3b..fc18bc63 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs @@ -1,3 +1,4 @@ +using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers; using System.Globalization; using CliFx.Attributes; using CliFx.Exceptions; @@ -210,7 +211,7 @@ public class HistoryReadCommand : CommandBase : StatusCode.IsBad(dv.StatusCode) ? "Bad" : "Uncertain"; await console.Output.WriteLineAsync( - $"{dv.SourceTimestamp.ToString("O"),-35} {ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(dv.WrappedValue)?.ToString() ?? "(none)",-15} {status,-10} " + + $"{dv.SourceTimestamp.ToString("O"),-35} {ValueConverter.ToClrObject(dv.WrappedValue)?.ToString() ?? "(none)",-15} {status,-10} " + $"{e.UpdateType,-8} {e.UserName ?? "",-20} {e.ModificationTime:O}"); } @@ -244,7 +245,7 @@ public class HistoryReadCommand : CommandBase : StatusCode.IsBad(dv.StatusCode) ? "Bad" : "Uncertain"; await console.Output.WriteLineAsync( - $"{dv.SourceTimestamp.ToString("O"),-35} {ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(dv.WrappedValue),-15} {status}"); + $"{dv.SourceTimestamp.ToString("O"),-35} {ValueConverter.ToClrObject(dv.WrappedValue),-15} {status}"); } await console.Output.WriteLineAsync(); @@ -294,7 +295,7 @@ public class HistoryReadCommand : CommandBase /// Formats one projected event field for the console: byte strings as hex, times as ISO 8601, null as -. /// The field value. /// Its display text. - private static string FormatEventField(Variant field) => ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(field) switch + private static string FormatEventField(Variant field) => ValueConverter.ToClrObject(field) switch { null => "-", byte[] bytes => Convert.ToHexString(bytes), diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryUpdateCommand.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryUpdateCommand.cs index 2dcb4d85..7f6bee74 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryUpdateCommand.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryUpdateCommand.cs @@ -1,3 +1,4 @@ +using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers; using System.Globalization; using CliFx.Attributes; using CliFx.Exceptions; @@ -93,7 +94,7 @@ public class HistoryUpdateCommand : CommandBase for (var i = 0; i < values.Count; i++) { var ts = ParseTime(times[i], "--time"); - updates.Add(new DataValue(ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToVariant(ParseValue(values[i], Type)), StatusCodes.Good, ts, ts)); + updates.Add(new DataValue(ValueConverter.ToVariant(ParseValue(values[i], Type)), StatusCodes.Good, ts, ts)); } return new UpdateDataDetails diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/ReadCommand.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/ReadCommand.cs index 93121dac..5677b908 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/ReadCommand.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/ReadCommand.cs @@ -1,3 +1,4 @@ +using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers; using CliFx.Attributes; using CliFx.Exceptions; using CliFx.Infrastructure; @@ -64,7 +65,7 @@ public class ReadCommand : CommandBase } await console.Output.WriteLineAsync($"Node: {NodeId}"); - await console.Output.WriteLineAsync($"Value: {ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(value.WrappedValue)}"); + await console.Output.WriteLineAsync($"Value: {ValueConverter.ToClrObject(value.WrappedValue)}"); await console.Output.WriteLineAsync($"Status: {value.StatusCode}"); await console.Output.WriteLineAsync($"Source Time: {value.SourceTimestamp:O}"); await console.Output.WriteLineAsync($"Server Time: {value.ServerTimestamp:O}"); diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/SubscribeCommand.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/SubscribeCommand.cs index 67876f65..13d37903 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/SubscribeCommand.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/SubscribeCommand.cs @@ -1,3 +1,4 @@ +using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers; using System.Collections.Concurrent; using System.Threading.Channels; using CliFx.Attributes; @@ -123,14 +124,14 @@ public class SubscribeCommand : CommandBase try { var key = e.NodeId.ToString(); - lastStatus[key] = (e.Value.StatusCode, DateTime.UtcNow, ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(e.Value.WrappedValue)); + lastStatus[key] = (e.Value.StatusCode, DateTime.UtcNow, ValueConverter.ToClrObject(e.Value.WrappedValue)); updateCount.AddOrUpdate(key, 1, (_, v) => v + 1); if (!StatusCode.IsGood(e.Value.StatusCode)) everBad.TryAdd(key, 0); if (!Quiet) { var line = - $"[{e.Value.SourceTimestamp:O}] {displayNameByNodeId.GetValueOrDefault(key, key)} = {ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(e.Value.WrappedValue)} ({e.Value.StatusCode})"; + $"[{e.Value.SourceTimestamp:O}] {displayNameByNodeId.GetValueOrDefault(key, key)} = {ValueConverter.ToClrObject(e.Value.WrappedValue)} ({e.Value.StatusCode})"; outputChannel.Writer.TryWrite(line); } } diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/WriteCommand.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/WriteCommand.cs index 9b288ad4..1810d286 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/WriteCommand.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/WriteCommand.cs @@ -54,7 +54,7 @@ public class WriteCommand : CommandBase // Read current value to determine type for conversion var currentValue = await service.ReadValueAsync(nodeId, ct); - var typedValue = ValueConverter.ConvertValue(Value, ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(currentValue.WrappedValue)); + var typedValue = ValueConverter.ConvertValue(Value, ValueConverter.ToClrObject(currentValue.WrappedValue)); var statusCode = await service.WriteValueAsync(nodeId, typedValue, ct); diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultApplicationConfigurationFactory.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultApplicationConfigurationFactory.cs index 0f7eedc0..ba33fd53 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultApplicationConfigurationFactory.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultApplicationConfigurationFactory.cs @@ -70,7 +70,7 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi ApplicationType = ApplicationType.Client, ApplicationConfiguration = config }; - await app.CheckApplicationInstanceCertificatesAsync(false, ct: ct); + await app.CheckApplicationInstanceCertificatesAsync(false, lifeTimeInMonths: 2048, ct: ct); } } catch diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionAdapter.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionAdapter.cs index e140a924..70025bdd 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionAdapter.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionAdapter.cs @@ -115,18 +115,14 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter { // Pass the caller's token so a cancelled browse does not block on the continuation // round-trip. - var response = await _session.BrowseNextAsync(null, false, [ByteString.From(continuationPoint)], ct); - if (response.Results.Count == 0) - throw new ServiceResultException(response.ResponseHeader.ServiceResult, "BrowseNext response contained no results."); - var result = response.Results[0]; - if (StatusCode.IsBad(result.StatusCode)) throw new ServiceResultException(result.StatusCode); - return (result.ContinuationPoint.IsNull ? null : result.ContinuationPoint.ToArray(), result.References); + var (_, nextCp, nextRefs) = await _session.BrowseNextAsync(null, false, ByteString.From(continuationPoint), ct); + return (nextCp.IsNull ? null : nextCp.ToArray(), nextRefs); } /// public async Task ReleaseBrowseContinuationPointAsync(byte[] continuationPoint, CancellationToken ct) { - await _session.BrowseNextAsync(null, true, [ByteString.From(continuationPoint)], ct).ConfigureAwait(false); + await _session.BrowseNextAsync(null, true, ByteString.From(continuationPoint), ct).ConfigureAwait(false); } /// @@ -276,9 +272,20 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter if (results.Count > 0) { var result = results[0]; - HistoryReadStatus.ThrowIfRefused(result.StatusCode, nodeId); - if (result.HistoryData.TryGetValue(out HistoryData? historyData)) - allValues.AddRange(historyData.DataValues); + try + { + HistoryReadStatus.ThrowIfRefused(result.StatusCode, nodeId); + if (result.HistoryData.TryGetValue(out HistoryData? historyData)) + allValues.AddRange(historyData.DataValues); + } + finally + { + // Keep the existing one-shot processed read; it owns no outstanding server cursor on return. + if (!result.ContinuationPoint.IsEmpty) + await HistoryReadPaging.ReleaseAsync( + (cp, release, token) => ReadHistoryPageAsync(details, nodeId, cp, release, TimestampsToReturn.Source, token), + result.ContinuationPoint.ToArray(), nodeId).ConfigureAwait(false); + } } return allValues; diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSubscriptionAdapter.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSubscriptionAdapter.cs index 6473abf6..6a49f91c 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSubscriptionAdapter.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSubscriptionAdapter.cs @@ -14,6 +14,8 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter private readonly Subscription _subscription; private readonly Func? _detach; private int _detached; + private int _finishing; + private readonly TaskCompletionSource _finished = new(TaskCreationOptions.RunContinuationsAsynchronously); /// /// Wraps a live OPC UA subscription so client code can manage monitored items through a testable abstraction. @@ -47,7 +49,7 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter item.Notification += (_, e) => { - if (e.NotificationValue is MonitoredItemNotification notification) + if (Volatile.Read(ref _finishing) == 0 && e.NotificationValue is MonitoredItemNotification notification) onDataChange(nodeId.ToString(), notification.Value); }; @@ -90,7 +92,7 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter item.Notification += (_, e) => { - if (e.NotificationValue is EventFieldList eventFields) onEvent(eventFields); + if (Volatile.Read(ref _finishing) == 0 && e.NotificationValue is EventFieldList eventFields) onEvent(eventFields); }; _subscription.AddItem(item); @@ -112,19 +114,28 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter /// public async Task DeleteAsync(CancellationToken ct) { + if (Interlocked.Exchange(ref _finishing, 1) != 0) + { + await _finished.Task.WaitAsync(ct).ConfigureAwait(false); + return; + } try { - // Forward the caller's token so the delete can be cancelled. - await _subscription.DeleteAsync(true, ct); - } - catch (Exception ex) - { - Logger.Warning(ex, "Error deleting subscription"); - } + try { await _subscription.DeleteAsync(true, ct).ConfigureAwait(false); } + catch (Exception ex) { Logger.Warning(ex, "Error deleting subscription"); } - // Detach whether or not the server-side delete succeeded: the adapter is finished with it either way. - await DetachAsync(ct); - _monitoredItems.Clear(); + // A cancelled delete still has local resources to release. Cleanup gets its own bounded token. + using var cleanup = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try { await DetachAsync(cleanup.Token).WaitAsync(cleanup.Token).ConfigureAwait(false); } + catch (Exception ex) { Logger.Warning(ex, "Error detaching subscription"); } + finally + { + _monitoredItems.Clear(); + try { await Task.Run(_subscription.Dispose).WaitAsync(cleanup.Token).ConfigureAwait(false); } + catch (Exception ex) { Logger.Warning(ex, "Error disposing subscription resources"); } + } + } + finally { _finished.TrySetResult(); } } private async Task DetachAsync(CancellationToken ct) @@ -145,21 +156,6 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter /// public void Dispose() { - // One bounded bridge (5 s) for delete + detach, so a dispose never blocks longer than before (review m6). - // DetachAsync swallows its own failures; a failed delete must not skip the detach. - try - { - DisposeBridge.Run(async ct => - { - try { await _subscription.DeleteAsync(true, ct); } - catch (Exception ex) { Logger.Warning(ex, "Error deleting subscription"); } - await DetachAsync(ct); - }); - } - catch - { - } - - _monitoredItems.Clear(); + DisposeBridge.Run(DeleteAsync); } } diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/HistoryReadPaging.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/HistoryReadPaging.cs index 51405b13..c1b30a83 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/HistoryReadPaging.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/HistoryReadPaging.cs @@ -61,10 +61,10 @@ internal static class HistoryReadPaging if (all.Count >= maxValues) break; } } - catch (OperationCanceledException) + catch { - // Cancelled between pages (or during page N+1) with a cursor outstanding: release it rather than leave it - // occupying one of the session's continuation-point slots, then surface the cancellation. + // A refused/failed/cancelled continuation still leaves the last cursor owned by this read. + // Best-effort release must preserve the original operation failure. if (continuationPoint is { Length: > 0 }) await ReleaseAsync(readPage, continuationPoint, nodeId); throw; } @@ -81,12 +81,12 @@ internal static class HistoryReadPaging return all; } - private static async Task ReleaseAsync(PageReader readPage, byte[] continuationPoint, NodeId nodeId) + internal static async Task ReleaseAsync(PageReader readPage, byte[] continuationPoint, NodeId nodeId) { try { using var cts = new CancellationTokenSource(ReleaseTimeout); - await readPage(continuationPoint, true, cts.Token).ConfigureAwait(false); + await readPage(continuationPoint, true, cts.Token).WaitAsync(cts.Token).ConfigureAwait(false); } catch (Exception ex) { diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/ValueConverter.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/ValueConverter.cs index a2746d0f..bd161882 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/ValueConverter.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/ValueConverter.cs @@ -15,20 +15,22 @@ public static class ValueConverter public static Variant ToVariant(object? value) { // Historically a CLR byte[] supplied to a client write/method was a scalar ByteString. - if (value is byte[] bytes) return Variant.From(ByteString.From(bytes.AsSpan())); - if (value is ByteString byteString) return Variant.From(byteString.Copy()); + if (value is byte[] bytes && bytes.GetType() == typeof(byte[])) return Variant.From(ByteString.From(bytes.AsSpan())); + if (value is ByteString byteString) return Variant.From(byteString.IsNull ? default : byteString.Copy()); if (!VariantHelper.TryCastFrom(value, out Variant variant)) throw new ServiceResultException(StatusCodes.BadTypeMismatch, "Unsupported client value type or shape."); if (variant.TypeInfo.IsMatrix) throw new ServiceResultException(StatusCodes.BadTypeMismatch, "Unsupported client matrix shape."); + if (variant.TypeInfo.IsArray && variant.TryGetValue(out ArrayOf buffers)) + return Variant.From(buffers.IsNull ? default : buffers.ConvertAll(b => b.IsNull ? default : b.Copy())); // ArrayOf is read-only, but can wrap externally owned ReadOnlyMemory. Detach it too. - if (variant.TypeInfo.IsArray && variant.AsBoxedObject(Variant.BoxingBehavior.Legacy) is Array array) + if (value is not Array && variant.TypeInfo.IsArray && variant.AsBoxedObject(Variant.BoxingBehavior.Legacy) is Array array) { if (!VariantHelper.TryCastFrom(array, out variant)) throw new ServiceResultException(StatusCodes.BadTypeMismatch, "Unsupported client array type."); } - else if (variant.TryGetValue(out ByteString retainedBytes)) - variant = Variant.From(retainedBytes.Copy()); + else if (variant.TypeInfo.IsScalar && variant.TryGetValue(out ByteString retainedBytes)) + variant = Variant.From(retainedBytes.IsNull ? default : retainedBytes.Copy()); return variant; } diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs index d6a4c817..513fb30f 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs @@ -310,7 +310,7 @@ public sealed class OpcUaClientService : IOpcUaClientService } var handle = await subscription.AddDataChangeMonitoredItemAsync( - nodeId, intervalMs, OnDataChangeNotification, ct); + nodeId, intervalMs, DataCallback(session, subscription), ct); lock (_subscriptionLock) { @@ -394,7 +394,7 @@ public sealed class OpcUaClientService : IOpcUaClientService var filter = CreateAlarmEventFilter(); await subscription.AddEventMonitoredItemAsync( - monitorNode, intervalMs, filter, OnAlarmEventNotification, ct); + monitorNode, intervalMs, filter, AlarmCallback(session, subscription), ct); lock (_subscriptionLock) { @@ -989,7 +989,7 @@ public sealed class OpcUaClientService : IOpcUaClientService } var handle = await dataSubscription.AddDataChangeMonitoredItemAsync( - nodeId, intervalMs, OnDataChangeNotification, CancellationToken.None); + nodeId, intervalMs, DataCallback(session, dataSubscription), CancellationToken.None); lock (_subscriptionLock) { @@ -1012,7 +1012,7 @@ public sealed class OpcUaClientService : IOpcUaClientService lock (_subscriptionLock) _alarmSubscription = replayedAlarms; var filter = CreateAlarmEventFilter(); await replayedAlarms.AddEventMonitoredItemAsync( - monitorNode, intervalMs, filter, OnAlarmEventNotification, CancellationToken.None); + monitorNode, intervalMs, filter, AlarmCallback(session, replayedAlarms), CancellationToken.None); lock (_subscriptionLock) { @@ -1026,6 +1026,25 @@ public sealed class OpcUaClientService : IOpcUaClientService } } + private bool IsCurrentNotification(ISessionAdapter session, ISubscriptionAdapter subscription, bool alarm) + { + lock (_subscriptionLock) + return !_disposed && _state == ConnectionState.Connected && ReferenceEquals(_session, session) + && ReferenceEquals(alarm ? _alarmSubscription : _dataSubscription, subscription); + } + + private Action DataCallback(ISessionAdapter session, ISubscriptionAdapter subscription) => + (nodeId, value) => + { + if (IsCurrentNotification(session, subscription, false)) OnDataChangeNotification(nodeId, value); + }; + + private Action AlarmCallback(ISessionAdapter session, ISubscriptionAdapter subscription) => + fields => + { + if (IsCurrentNotification(session, subscription, true)) OnAlarmEventNotification(fields); + }; + private void OnDataChangeNotification(string nodeId, DataValue value) { DataChanged?.Invoke(this, new DataChangedEventArgs(nodeId, value)); diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/HistoryViewModel.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/HistoryViewModel.cs index 15ca66cb..08f17cb3 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/HistoryViewModel.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/HistoryViewModel.cs @@ -1,3 +1,4 @@ +using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers; using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -97,7 +98,7 @@ public partial class HistoryViewModel : ObservableObject { foreach (var dv in values) Results.Add(new HistoryValueViewModel( - Helpers.ValueFormatter.Format(ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(dv.WrappedValue)), + Helpers.ValueFormatter.Format(ValueConverter.ToClrObject(dv.WrappedValue)), Helpers.StatusCodeFormatter.Format(dv.StatusCode), dv.SourceTimestamp.ToString("O"), dv.ServerTimestamp.ToString("O"))); diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/ReadWriteViewModel.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/ReadWriteViewModel.cs index e312f11f..e56d4c8c 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/ReadWriteViewModel.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/ReadWriteViewModel.cs @@ -1,3 +1,4 @@ +using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Opc.Ua; @@ -76,7 +77,7 @@ public partial class ReadWriteViewModel : ObservableObject _dispatcher.Post(() => { - CurrentValue = Helpers.ValueFormatter.Format(ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(dataValue.WrappedValue)); + CurrentValue = Helpers.ValueFormatter.Format(ValueConverter.ToClrObject(dataValue.WrappedValue)); CurrentStatus = Helpers.StatusCodeFormatter.Format(dataValue.StatusCode); SourceTimestamp = dataValue.SourceTimestamp.ToString("O"); ServerTimestamp = dataValue.ServerTimestamp.ToString("O"); diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/SubscriptionsViewModel.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/SubscriptionsViewModel.cs index 4bfc2b4b..08bbbe9d 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/SubscriptionsViewModel.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/SubscriptionsViewModel.cs @@ -1,3 +1,4 @@ +using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers; using System.Collections.ObjectModel; using System.Collections.Specialized; using CommunityToolkit.Mvvm.ComponentModel; @@ -75,7 +76,7 @@ public partial class SubscriptionsViewModel : ObservableObject if (!_rowsByNodeId.TryGetValue(e.NodeId, out var rows)) return; foreach (var item in rows) { - item.Value = Helpers.ValueFormatter.Format(ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(e.Value.WrappedValue)); + item.Value = Helpers.ValueFormatter.Format(ValueConverter.ToClrObject(e.Value.WrappedValue)); item.Status = Helpers.StatusCodeFormatter.Format(e.Value.StatusCode); item.Timestamp = e.Value.SourceTimestamp.ToString("O"); } @@ -281,7 +282,7 @@ public partial class SubscriptionsViewModel : ObservableObject // Read current value to determine target type var currentDataValue = await _service.ReadValueAsync(nodeId); - var currentValue = ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(currentDataValue.WrappedValue); + var currentValue = ValueConverter.ToClrObject(currentDataValue.WrappedValue); // Try parsing to the target type before writing try diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/AlarmsCommandTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/AlarmsCommandTests.cs index 4f9ce7fa..9e53195f 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/AlarmsCommandTests.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/AlarmsCommandTests.cs @@ -54,7 +54,7 @@ public class AlarmsCommandTests fakeService.SubscribeAlarmsCalls.Count.ShouldBe(1); fakeService.SubscribeAlarmsCalls[0].SourceNodeId.ShouldNotBeNull(); - fakeService.SubscribeAlarmsCalls[0].SourceNodeId!.Identifier.ShouldBe("AlarmSource"); + fakeService.SubscribeAlarmsCalls[0].SourceNodeId!.Value.IdentifierAsString.ShouldBe("AlarmSource"); } /// Verifies that Execute with refresh requests condition refresh. diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/BrowseCommandTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/BrowseCommandTests.cs index 5cf87e5d..e48744d3 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/BrowseCommandTests.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/BrowseCommandTests.cs @@ -56,7 +56,7 @@ public class BrowseCommandTests fakeService.BrowseNodeIds.Count.ShouldBe(1); fakeService.BrowseNodeIds[0].ShouldNotBeNull(); - fakeService.BrowseNodeIds[0]!.Identifier.ShouldBe("StartNode"); + fakeService.BrowseNodeIds[0]!.Value.IdentifierAsString.ShouldBe("StartNode"); } /// Verifies that Execute browses from null node when not specified. diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/HistoryReadCommandTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/HistoryReadCommandTests.cs index 62c8e139..0e2e1763 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/HistoryReadCommandTests.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/HistoryReadCommandTests.cs @@ -61,7 +61,7 @@ public class HistoryReadCommandTests fakeService.HistoryReadRawCalls.Count.ShouldBe(1); fakeService.HistoryReadRawCalls[0].MaxValues.ShouldBe(500); - fakeService.HistoryReadRawCalls[0].NodeId.Identifier.ShouldBe("HistNode"); + fakeService.HistoryReadRawCalls[0].NodeId.IdentifierAsString.ShouldBe("HistNode"); } /// Verifies AggregateRead execution calls HistoryReadAggregate. @@ -211,11 +211,11 @@ public class HistoryReadCommandTests var call = fakeService.HistoryReadEventsCalls.ShouldHaveSingleItem(); call.MaxEvents.ShouldBe(25); - call.Filter.SelectClauses.Select(c => c.BrowsePath.ShouldHaveSingleItem().Name) + call.Filter.SelectClauses.ToArray()!.Select(c => c.BrowsePath.ToArray()!.ShouldHaveSingleItem().Name) .ShouldBe(["EventId", "EventType", "Time", "SourceName", "Severity", "Message", "ConditionName", "SourceNode"]); - call.Filter.SelectClauses.Single(c => c.BrowsePath[0].Name == "ConditionName").TypeDefinitionId + call.Filter.SelectClauses.ToArray()!.Single(c => c.BrowsePath[0].Name == "ConditionName").TypeDefinitionId .ShouldBe(ObjectTypeIds.ConditionType); - call.Filter.WhereClause.Elements.ShouldBeEmpty("no --of-type, no where-clause"); + call.Filter.WhereClause.Elements.ToArray()!.ShouldBeEmpty("no --of-type, no where-clause"); fakeService.HistoryReadRawCalls.ShouldBeEmpty(); var output = TestConsoleHelper.GetOutput(console); @@ -243,10 +243,10 @@ public class HistoryReadCommandTests await command.ExecuteAsync(console); var filter = fakeService.HistoryReadEventsCalls.ShouldHaveSingleItem().Filter; - filter.SelectClauses.Select(c => c.BrowsePath[0].Name).ShouldBe(["EventType", "Severity"]); - var element = filter.WhereClause.Elements.ShouldHaveSingleItem(); + filter.SelectClauses.ToArray()!.Select(c => c.BrowsePath[0].Name).ShouldBe(["EventType", "Severity"]); + var element = filter.WhereClause.Elements.ToArray()!.ShouldHaveSingleItem(); element.FilterOperator.ShouldBe(FilterOperator.OfType); - ((LiteralOperand)ExtensionObject.ToEncodeable(element.FilterOperands[0])).Value.Value + (element.FilterOperands[0].TryGetValue(out LiteralOperand? literal) ? literal.Value : Variant.Null) .ShouldBe(ObjectTypeIds.LimitAlarmType); TestConsoleHelper.GetOutput(console).ShouldContain("0 events returned."); } diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/HistoryUpdateCommandTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/HistoryUpdateCommandTests.cs index 1074ae1f..d0eb20b4 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/HistoryUpdateCommandTests.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/HistoryUpdateCommandTests.cs @@ -33,9 +33,9 @@ public class HistoryUpdateCommandTests .BuildDetails().ShouldBeOfType(); d.PerformInsertReplace.ShouldBe(PerformUpdateType.Insert); d.UpdateValues.Count.ShouldBe(2); - d.UpdateValues[1].Value.ShouldBe(2.5); + d.UpdateValues[1].WrappedValue.ShouldBe(Variant.From(2.5)); d.UpdateValues[0].SourceTimestamp.ShouldBe(new DateTime(2026, 1, 15, 8, 0, 0, DateTimeKind.Utc)); - d.NodeId.Identifier.ShouldBe("HistNode"); + d.NodeId.IdentifierAsString.ShouldBe("HistNode"); } /// Replace and update map to their update types. @@ -60,7 +60,7 @@ public class HistoryUpdateCommandTests { var d = Make(values: [value], times: ["2026-01-15T08:00:00Z"], type: type) .BuildDetails().ShouldBeOfType(); - d.UpdateValues[0].Value.ShouldBeOfType(expected); + ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(d.UpdateValues[0].WrappedValue).ShouldBeOfType(expected); } /// delete-raw builds DeleteRawModifiedDetails with IsDeleteModified=false. @@ -211,7 +211,7 @@ public class HistoryUpdateCommandTests public async Task Execute_UnnamedStatus_PrintsHex() { const uint unnamed = 0x8FFF0000u; - StatusCodes.GetBrowseName(unnamed).ShouldBeNullOrEmpty(); // precondition: genuinely unnamed + new StatusCode(unnamed).SymbolicId.ShouldBeNullOrEmpty(); // precondition: genuinely unnamed var svc = new FakeOpcUaClientService { HistoryUpdateResult = new HistoryUpdateOutcome(StatusCodes.Good, [StatusCodes.Good], diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/NodeIdParserTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/NodeIdParserTests.cs index 2b018508..0d837a20 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/NodeIdParserTests.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/NodeIdParserTests.cs @@ -34,8 +34,8 @@ public class NodeIdParserTests { var result = NodeIdParser.Parse("ns=2;s=MyNode"); result.ShouldNotBeNull(); - result.NamespaceIndex.ShouldBe((ushort)2); - result.Identifier.ShouldBe("MyNode"); + result.Value.NamespaceIndex.ShouldBe((ushort)2); + result.Value.IdentifierAsString.ShouldBe("MyNode"); } /// Verifies that Parse correctly parses numeric NodeId format (i=85). @@ -44,7 +44,7 @@ public class NodeIdParserTests { var result = NodeIdParser.Parse("i=85"); result.ShouldNotBeNull(); - result.IdType.ShouldBe(IdType.Numeric); + result.Value.IdType.ShouldBe(IdType.Numeric); } /// Verifies that Parse treats bare numeric input as namespace 0 numeric NodeId. @@ -53,8 +53,9 @@ public class NodeIdParserTests { var result = NodeIdParser.Parse("85"); result.ShouldNotBeNull(); - result.NamespaceIndex.ShouldBe((ushort)0); - result.Identifier.ShouldBe((uint)85); + result.Value.NamespaceIndex.ShouldBe((ushort)0); + result.Value.TryGetValue(out uint identifier).ShouldBeTrue(); + identifier.ShouldBe(85u); } /// Verifies that Parse trims whitespace padding from input. @@ -63,7 +64,7 @@ public class NodeIdParserTests { var result = NodeIdParser.Parse(" ns=2;s=MyNode "); result.ShouldNotBeNull(); - result.Identifier.ShouldBe("MyNode"); + result.Value.IdentifierAsString.ShouldBe("MyNode"); } /// Verifies that Parse throws FormatException for invalid input. @@ -92,7 +93,36 @@ public class NodeIdParserTests public void ParseRequired_ValidInput_ReturnsNodeId() { var result = NodeIdParser.ParseRequired("ns=2;s=TestNode"); - result.ShouldNotBeNull(); - result.Identifier.ShouldBe("TestNode"); + result.IsNull.ShouldBeFalse(); + result.IdentifierAsString.ShouldBe("TestNode"); + } + + [Fact] + public void Parse_OpaqueIdentifier_PreservesBytes() + { + var node = NodeIdParser.ParseRequired("ns=3;b=AQID"); + node.IdType.ShouldBe(IdType.Opaque); + node.NamespaceIndex.ShouldBe((ushort)3); + node.TryGetValue(out ByteString bytes).ShouldBeTrue(); + bytes.ToArray().ShouldBe(new byte[] { 1, 2, 3 }); + } + + [Fact] + public void Parse_StringIdentifierContainingEncodedText_StaysLiteral() + { + var node = NodeIdParser.ParseRequired("ns=4;s=ns=2;b=AQID"); + node.IdType.ShouldBe(IdType.String); + node.NamespaceIndex.ShouldBe((ushort)4); + node.TryGetValue(out string? identifier).ShouldBeTrue(); + identifier.ShouldBe("ns=2;b=AQID"); + } + + [Theory] + [InlineData("ns=bad;i=1")] + [InlineData("ns=2;b=invalid!")] + [InlineData("ns=2;i=invalid")] + public void Parse_InvalidEncodedIdentifier_IsRejected(string input) + { + Should.Throw(() => NodeIdParser.ParseRequired(input)); } } \ No newline at end of file diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ReadCommandTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ReadCommandTests.cs index 1dddb1bc..24893386 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ReadCommandTests.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ReadCommandTests.cs @@ -56,7 +56,7 @@ public class ReadCommandTests await command.ExecuteAsync(console); fakeService.ReadNodeIds.Count.ShouldBe(1); - fakeService.ReadNodeIds[0].Identifier.ShouldBe("MyVariable"); + fakeService.ReadNodeIds[0].IdentifierAsString.ShouldBe("MyVariable"); } /// Verifies that execute disconnects in finally. diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/SubscribeCommandSummaryTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/SubscribeCommandSummaryTests.cs index 8c265ada..b4c68d57 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/SubscribeCommandSummaryTests.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/SubscribeCommandSummaryTests.cs @@ -222,8 +222,8 @@ public class SubscribeCommandSummaryTests // Both Variables (depth 1 + depth 2) should have been subscribed. fakeService.SubscribeCalls.Count.ShouldBeGreaterThanOrEqualTo(2); - fakeService.SubscribeCalls.ShouldContain(c => c.NodeId.Identifier.ToString() == "Tag1"); - fakeService.SubscribeCalls.ShouldContain(c => c.NodeId.Identifier.ToString() == "Tag2"); + fakeService.SubscribeCalls.ShouldContain(c => c.NodeId.IdentifierAsString.ToString() == "Tag1"); + fakeService.SubscribeCalls.ShouldContain(c => c.NodeId.IdentifierAsString.ToString() == "Tag2"); var output = TestConsoleHelper.GetOutput(console); output.ShouldContain("Browsing subtree of ns=2;s=Root"); diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/SubscribeCommandTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/SubscribeCommandTests.cs index 82454a38..ed527028 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/SubscribeCommandTests.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/SubscribeCommandTests.cs @@ -33,7 +33,7 @@ public class SubscribeCommandTests fakeService.SubscribeCalls.Count.ShouldBe(1); fakeService.SubscribeCalls[0].IntervalMs.ShouldBe(500); - fakeService.SubscribeCalls[0].NodeId.Identifier.ShouldBe("TestVar"); + fakeService.SubscribeCalls[0].NodeId.IdentifierAsString.ShouldBe("TestVar"); } /// Verifies that ExecuteAsync unsubscribes when cancellation is requested. diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Adapters/DefaultSubscriptionAdapterTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Adapters/DefaultSubscriptionAdapterTests.cs index 7d728616..eea5ad7f 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Adapters/DefaultSubscriptionAdapterTests.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Adapters/DefaultSubscriptionAdapterTests.cs @@ -11,6 +11,48 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Adapters; /// public class DefaultSubscriptionAdapterTests { + private sealed class CountingSubscription() : Subscription(ClientTelemetry.Context) + { + public int DisposeCount; + protected override void Dispose(bool disposing) + { + if (disposing) Interlocked.Increment(ref DisposeCount); + base.Dispose(disposing); + } + } + + [Fact] + public async Task DeleteAndDispose_ReleaseSdkResourcesExactlyOnce() + { + var subscription = new CountingSubscription(); + var detached = 0; + var adapter = new DefaultSubscriptionAdapter(subscription, (_, _) => + { + Interlocked.Increment(ref detached); + return Task.CompletedTask; + }); + await adapter.DeleteAsync(TestContext.Current.CancellationToken); + adapter.Dispose(); + adapter.Dispose(); + detached.ShouldBe(1); + subscription.DisposeCount.ShouldBe(1); + } + + [Fact] + public async Task ConcurrentTeardown_HasOneSdkResourceOwner() + { + var subscription = new CountingSubscription(); + var detached = 0; + var adapter = new DefaultSubscriptionAdapter(subscription, (_, _) => + { + Interlocked.Increment(ref detached); + return Task.CompletedTask; + }); + await Task.WhenAll(Task.Run(adapter.Dispose, TestContext.Current.CancellationToken), adapter.DeleteAsync(TestContext.Current.CancellationToken)); + detached.ShouldBe(1); + subscription.DisposeCount.ShouldBe(1); + } + /// DeleteAsync detaches the wrapped subscription exactly once, even when Dispose follows. [Fact] public async Task DeleteAsync_detaches_the_subscription_from_its_session_once() diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Adapters/EndpointSelectorTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Adapters/EndpointSelectorTests.cs index ba917d82..4dd88a22 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Adapters/EndpointSelectorTests.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Adapters/EndpointSelectorTests.cs @@ -117,7 +117,7 @@ public class EndpointSelectorTests var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://external-host:4840", MessageSecurityMode.Sign); - new Uri(best.EndpointUrl).Host.ShouldBe("external-host"); + new Uri(best.EndpointUrl.ShouldNotBeNull()).Host.ShouldBe("external-host"); } /// @@ -169,7 +169,7 @@ public class EndpointSelectorTests var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://localhost:4841", MessageSecurityMode.None); - var uri = new Uri(best.EndpointUrl); + var uri = new Uri(best.EndpointUrl.ShouldNotBeNull()); uri.Host.ShouldBe("localhost"); uri.Port.ShouldBe(4841); uri.AbsolutePath.ShouldBe("/OtOpcUa"); @@ -183,8 +183,8 @@ public class EndpointSelectorTests var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://localhost:4841", MessageSecurityMode.None); - new Uri(best.EndpointUrl).Host.ShouldBe("internal-host"); - new Uri(best.EndpointUrl).Port.ShouldBe(4843); + new Uri(best.EndpointUrl.ShouldNotBeNull()).Host.ShouldBe("internal-host"); + new Uri(best.EndpointUrl.ShouldNotBeNull()).Port.ShouldBe(4843); } /// Identical host, port and scheme: nothing to rewrite. @@ -206,7 +206,7 @@ public class EndpointSelectorTests var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://localhost", MessageSecurityMode.None); - var uri = new Uri(best.EndpointUrl); + var uri = new Uri(best.EndpointUrl.ShouldNotBeNull()); uri.Host.ShouldBe("localhost"); uri.Port.ShouldBe(4840); } @@ -219,7 +219,7 @@ public class EndpointSelectorTests var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://[::1]:4841", MessageSecurityMode.None); - var uri = new Uri(best.EndpointUrl); + var uri = new Uri(best.EndpointUrl.ShouldNotBeNull()); uri.Host.ShouldBe("[::1]"); uri.Port.ShouldBe(4841); } diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/AlarmEventFilterTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/AlarmEventFilterTests.cs index f0349bc5..348f61f9 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/AlarmEventFilterTests.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/AlarmEventFilterTests.cs @@ -18,7 +18,7 @@ public sealed class AlarmEventFilterTests { var filter = OpcUaClientService.CreateAlarmEventFilter(); - filter.SelectClauses.SelectMany(c => c.BrowsePath).ShouldAllBe(q => !q.Name.Contains('/')); + filter.SelectClauses.ToArray()!.SelectMany(c => c.BrowsePath.ToArray()!).ShouldAllBe(q => !(q.Name ?? "").Contains('/')); } /// The two-state ids are selected by their two-element browse paths, at the indices the parser reads. @@ -27,7 +27,7 @@ public sealed class AlarmEventFilterTests { var clauses = OpcUaClientService.CreateAlarmEventFilter().SelectClauses; - string Path(int i) => string.Join("/", clauses[i].BrowsePath.Select(q => q.Name)); + string Path(int i) => string.Join("/", clauses[i].BrowsePath.ToArray()!.Select(q => q.Name)); Path(8).ShouldBe("AckedState/Id"); clauses[8].BrowsePath.Count.ShouldBe(2); Path(9).ShouldBe("ActiveState/Id"); diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/FailoverEpochTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/FailoverEpochTests.cs index df4508b9..8cb7caae 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/FailoverEpochTests.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/FailoverEpochTests.cs @@ -164,7 +164,7 @@ public sealed class FailoverEpochTests : IDisposable var sub = new FakeSubscriptionAdapter(); _factory.EnqueueSession(new FakeSessionAdapter { NextSubscription = sub }); await _service.ConnectAsync(Settings(), TestContext.Current.CancellationToken); - var node = new Opc.Ua.NodeId("ns=2;s=X"); + var node = Opc.Ua.NodeId.Parse("ns=2;s=X"); await _service.SubscribeAsync(node, ct: TestContext.Current.CancellationToken); sub.ThrowOnRemove = true; diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeEndpointDiscovery.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeEndpointDiscovery.cs index ccbc3f99..373cec65 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeEndpointDiscovery.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeEndpointDiscovery.cs @@ -35,7 +35,7 @@ internal sealed class FakeEndpointDiscovery : IEndpointDiscovery : SecurityPolicies.Basic256Sha256, Server = new ApplicationDescription { - ApplicationName = "FakeServer", + ApplicationName = new LocalizedText("FakeServer"), ApplicationUri = "urn:localhost:FakeServer" } }); diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSessionAdapter.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSessionAdapter.cs index 27504765..120d6e27 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSessionAdapter.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSessionAdapter.cs @@ -48,11 +48,15 @@ internal sealed class FakeSessionAdapter : ISessionAdapter public bool ThrowOnBrowse { get; set; } /// Gets or sets the browse references returned by browse operations. - public ReferenceDescriptionCollection BrowseResponse { get; set; } = []; + public ArrayOf BrowseResponse { get; set; } = []; /// Gets or sets the continuation point for browse operations. public byte[]? BrowseContinuationPoint { get; set; } + public Func? BeforeBrowseNext { get; set; } + public List<(byte[] Point, bool Cancelled)> BrowseReleases { get; } = []; + public bool ThrowOnBrowseRelease { get; set; } + /// Gets or sets the browse references returned by browse-next operations. - public ReferenceDescriptionCollection BrowseNextResponse { get; set; } = []; + public ArrayOf BrowseNextResponse { get; set; } = []; /// Gets or sets the continuation point for browse-next operations. public byte[]? BrowseNextContinuationPoint { get; set; } @@ -129,7 +133,7 @@ internal sealed class FakeSessionAdapter : ISessionAdapter } /// - public Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseAsync( + public Task<(byte[]? ContinuationPoint, ArrayOf References)> BrowseAsync( NodeId nodeId, uint nodeClassMask, CancellationToken ct) { BrowseCount++; @@ -139,11 +143,19 @@ internal sealed class FakeSessionAdapter : ISessionAdapter } /// - public Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseNextAsync( + public async Task<(byte[]? ContinuationPoint, ArrayOf References)> BrowseNextAsync( byte[] continuationPoint, CancellationToken ct) { BrowseNextCount++; - return Task.FromResult((BrowseNextContinuationPoint, BrowseNextResponse)); + if (BeforeBrowseNext != null) await BeforeBrowseNext(ct); + return (BrowseNextContinuationPoint, BrowseNextResponse); + } + + public Task ReleaseBrowseContinuationPointAsync(byte[] continuationPoint, CancellationToken ct) + { + BrowseReleases.Add((continuationPoint, ct.IsCancellationRequested)); + if (ThrowOnBrowseRelease) throw new ServiceResultException(StatusCodes.BadSessionClosed); + return Task.CompletedTask; } /// diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSessionFactory.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSessionFactory.cs index 24cbd13a..521b6fbb 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSessionFactory.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSessionFactory.cs @@ -40,7 +40,7 @@ internal sealed class FakeSessionFactory : ISessionFactory uint sessionTimeoutMs, UserIdentity identity, CancellationToken ct) { CreateCallCount++; - LastEndpointUrl = endpoint.EndpointUrl; + LastEndpointUrl = endpoint.EndpointUrl ?? string.Empty; // One-shot: the gate holds only the call that observed it, so a later call (e.g. a user's Connect while a // failover is held) runs freely. @@ -60,14 +60,14 @@ internal sealed class FakeSessionFactory : ISessionFactory else session = new FakeSessionAdapter { - EndpointUrl = endpoint.EndpointUrl, - ServerName = endpoint.Server?.ApplicationName?.Text ?? "FakeServer", + EndpointUrl = endpoint.EndpointUrl ?? string.Empty, + ServerName = endpoint.Server?.ApplicationName.Text ?? "FakeServer", SecurityMode = endpoint.SecurityMode.ToString(), SecurityPolicyUri = endpoint.SecurityPolicyUri ?? string.Empty }; // Ensure endpoint URL matches - session.EndpointUrl = endpoint.EndpointUrl; + session.EndpointUrl = endpoint.EndpointUrl ?? string.Empty; _createdSessions.Add(session); return session; } diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSubscriptionAdapter.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSubscriptionAdapter.cs index 1e442575..b567b15c 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSubscriptionAdapter.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSubscriptionAdapter.cs @@ -129,6 +129,16 @@ internal sealed class FakeSubscriptionAdapter : ISubscriptionAdapter lock (_itemsLock) _items.Clear(); } + public Action CaptureDataCallback(uint handle) + { + lock (_itemsLock) return _items[handle].DataCallback!; + } + + public Action CaptureEventCallback(uint handle) + { + lock (_itemsLock) return _items[handle].EventCallback!; + } + /// /// Simulates a data change notification for testing. /// diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Helpers/AggregateTypeMapperTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Helpers/AggregateTypeMapperTests.cs index 267baf02..72823456 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Helpers/AggregateTypeMapperTests.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Helpers/AggregateTypeMapperTests.cs @@ -26,8 +26,7 @@ public class AggregateTypeMapperTests public void ToNodeId_ReturnsNonNullForAllValues(AggregateType aggregate) { var nodeId = AggregateTypeMapper.ToNodeId(aggregate); - nodeId.ShouldNotBeNull(); - nodeId.IsNullNodeId.ShouldBeFalse(); + nodeId.IsNull.ShouldBeFalse(); } /// Verifies that Average aggregate type maps to the correct OPC UA node ID. @@ -109,9 +108,9 @@ public class AggregateTypeMapperTests [MemberData(nameof(AllAggregates))] public void ToNodeId_maps_to_the_same_named_AggregateFunction(AggregateType aggregate) { - var field = typeof(ObjectIds).GetField("AggregateFunction_" + aggregate); - field.ShouldNotBeNull(); - AggregateTypeMapper.ToNodeId(aggregate).ShouldBe((NodeId)field.GetValue(null)!); + var property = typeof(ObjectIds).GetProperty("AggregateFunction_" + aggregate); + property.ShouldNotBeNull(); + AggregateTypeMapper.ToNodeId(aggregate).ShouldBe((NodeId)property.GetValue(null)!); } /// Every client aggregate. diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Helpers/HistoryReadPagingTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Helpers/HistoryReadPagingTests.cs index bd8c3d1c..82b70360 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Helpers/HistoryReadPagingTests.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Helpers/HistoryReadPagingTests.cs @@ -29,19 +29,18 @@ public class HistoryReadPagingTests Calls.Add(new Call(cp, release)); if (release) return Task.FromResult(new HistoryReadResult { StatusCode = StatusCodes.Good }); var (count, next) = _pages.Dequeue(); - var data = new HistoryData(); - for (var i = 0; i < count; i++) data.DataValues.Add(new DataValue(new Variant(i))); + var data = new HistoryData { DataValues = Enumerable.Range(0, count).Select(i => new DataValue(Variant.From(i))).ToArrayOf() }; return Task.FromResult(new HistoryReadResult { StatusCode = StatusCodes.Good, - ContinuationPoint = next, + ContinuationPoint = ByteString.From(next), HistoryData = new ExtensionObject(data) }); } } private static IEnumerable Values(HistoryReadResult r) => - r.HistoryData?.Body is HistoryData d ? d.DataValues : []; + r.HistoryData.TryGetValue(out HistoryData? d) ? d.DataValues.ToArray() ?? [] : []; /// /// A server page limit below the caller's cap hands back a continuation point: the follow-up must READ the next @@ -136,8 +135,7 @@ public class HistoryReadPagingTests calls.Add((cp, release, ct.IsCancellationRequested)); if (release) return Task.FromResult(new HistoryReadResult { StatusCode = StatusCodes.Good }); if (cp != null) { cts.Cancel(); ct.ThrowIfCancellationRequested(); } - var data = new HistoryData(); - data.DataValues.Add(new DataValue(new Variant(1))); + var data = new HistoryData { DataValues = [new DataValue(Variant.From(1))] }; return Task.FromResult(new HistoryReadResult { StatusCode = StatusCodes.Good, ContinuationPoint = [5], HistoryData = new ExtensionObject(data) @@ -165,8 +163,7 @@ public class HistoryReadPagingTests releaseSawCancelled = ct.IsCancellationRequested; return Task.FromResult(new HistoryReadResult { StatusCode = StatusCodes.Good }); } - var data = new HistoryData(); - data.DataValues.Add(new DataValue(new Variant(1))); + var data = new HistoryData { DataValues = [new DataValue(Variant.From(1))] }; cts.Cancel(); // the caller gives up right as the capped page arrives return Task.FromResult(new HistoryReadResult { @@ -189,8 +186,7 @@ public class HistoryReadPagingTests { calls++; if (release) throw new ServiceResultException(StatusCodes.BadSessionClosed); - var data = new HistoryData(); - data.DataValues.Add(new DataValue(new Variant(1))); + var data = new HistoryData { DataValues = [new DataValue(Variant.From(1))] }; return Task.FromResult(new HistoryReadResult { StatusCode = StatusCodes.Good, ContinuationPoint = [9], HistoryData = new ExtensionObject(data) @@ -216,4 +212,24 @@ public class HistoryReadPagingTests await Should.ThrowAsync(() => HistoryReadPaging.ReadAllAsync(Read, Values, maxValues: 10, Node, CancellationToken.None)); } + [Fact] + public async Task RefusedContinuationPage_ReleasesCursorAndPreservesStatus() + { + var releases = new List(); + Task Read(byte[]? cp, bool release, CancellationToken ct) + { + if (release) + { + releases.Add(cp!); + throw new ServiceResultException(StatusCodes.BadSessionClosed); + } + return Task.FromResult(cp == null + ? new HistoryReadResult { StatusCode = StatusCodes.Good, ContinuationPoint = [7] } + : new HistoryReadResult { StatusCode = StatusCodes.BadUserAccessDenied }); + } + var exception = await Should.ThrowAsync(() => + HistoryReadPaging.ReadAllAsync(Read, Values, 10, Node, TestContext.Current.CancellationToken)); + exception.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied); + releases.ShouldHaveSingleItem().ShouldBe(new byte[] { 7 }); + } } diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Helpers/HistoryReadStatusTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Helpers/HistoryReadStatusTests.cs index 91a70dc0..098e43be 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Helpers/HistoryReadStatusTests.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Helpers/HistoryReadStatusTests.cs @@ -7,32 +7,29 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Helpers; public class HistoryReadStatusTests { + public static IEnumerable Refusals() => new[] { StatusCodes.BadHistoryOperationUnsupported, + StatusCodes.BadUserAccessDenied, StatusCodes.BadNodeIdUnknown, StatusCodes.BadTimeout }.Select(s => new object[] { s }); + public static IEnumerable NonRefusals() => new[] { StatusCodes.Good, StatusCodes.GoodNoData, + StatusCodes.GoodMoreData, StatusCodes.UncertainNoCommunicationLastUsableValue, StatusCodes.BadNoData }.Select(s => new object[] { s }); /// A refused read must surface, carrying the status code, rather than read as an empty result. /// The Bad per-node status code. [Theory] - [InlineData(StatusCodes.BadHistoryOperationUnsupported)] - [InlineData(StatusCodes.BadUserAccessDenied)] - [InlineData(StatusCodes.BadNodeIdUnknown)] - [InlineData(StatusCodes.BadTimeout)] - public void Bad_status_throws_ServiceResultException_with_the_code(uint code) + [MemberData(nameof(Refusals))] + public void Bad_status_throws_ServiceResultException_with_the_code(StatusCode code) { var ex = Should.Throw( - () => HistoryReadStatus.ThrowIfRefused(new StatusCode(code), new NodeId("n", 2))); + () => HistoryReadStatus.ThrowIfRefused(code, new NodeId("n", 2))); - ex.StatusCode.ShouldBe(code); + ex.StatusCode.ShouldBe(code.Code); ex.Message.ShouldContain("ns=2;s=n"); } /// Good, GoodNoData, Uncertain and BadNoData are all non-refusals (the window may simply be empty). /// The non-refusal status code. [Theory] - [InlineData(StatusCodes.Good)] - [InlineData(StatusCodes.GoodNoData)] - [InlineData(StatusCodes.GoodMoreData)] - [InlineData(StatusCodes.UncertainNoCommunicationLastUsableValue)] - [InlineData(StatusCodes.BadNoData)] - public void Non_refusal_status_does_not_throw(uint code) + [MemberData(nameof(NonRefusals))] + public void Non_refusal_status_does_not_throw(StatusCode code) { - Should.NotThrow(() => HistoryReadStatus.ThrowIfRefused(new StatusCode(code), new NodeId("n", 2))); + Should.NotThrow(() => HistoryReadStatus.ThrowIfRefused(code, new NodeId("n", 2))); } } diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Helpers/VariantBoundaryTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Helpers/VariantBoundaryTests.cs new file mode 100644 index 00000000..ce6b8ba8 --- /dev/null +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Helpers/VariantBoundaryTests.cs @@ -0,0 +1,126 @@ +using Opc.Ua; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers; + +namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Helpers; + +public class VariantBoundaryTests +{ + public static IEnumerable Payloads() + { + foreach (var value in new object?[] { null, true, (sbyte)-1, (byte)1, (short)-2, (ushort)2, + -3, 3u, -4L, 4UL, 1.25f, 2.5, "", "text", Guid.Parse("de305d54-75b4-431b-adb2-eb6b9e546014"), + new DateTime(2026, 11, 10, 0, 0, 0, DateTimeKind.Utc), Array.Empty(), new byte[] { 1, 2 }, + Array.Empty(), new[] { 1 }, new[] { 1, 2 }, Array.Empty(), new[] { "", "text" } }) + yield return [value]; + foreach (var value in new object[] { true, (sbyte)-1, (byte)1, (short)-2, (ushort)2, + -3, 3u, -4L, 4UL, 1.25f, 2.5, "text", Guid.Empty, + new DateTime(2026, 11, 10, 0, 0, 0, DateTimeKind.Utc) }) + { + foreach (var count in new[] { 0, 1, 3 }) + { + var array = Array.CreateInstance(value.GetType(), count); + for (var i = 0; i < count; i++) array.SetValue(value, i); + yield return [array]; + } + } + } + + [Theory] + [MemberData(nameof(Payloads))] + public void RoundTrip_PreservesClrTypeAndValue(object? expected) + { + var actual = ValueConverter.ToClrObject(ValueConverter.ToVariant(expected)); + if (expected == null) actual.ShouldBeNull(); + else + { + actual.ShouldNotBeNull(); + actual.GetType().ShouldBe(expected.GetType()); + actual.ShouldBe(expected); + } + } + + [Fact] + public void RetainedArrays_AreDetachedFromCaller() + { + var bytes = new byte[] { 1, 2 }; + var numbers = new[] { 3, 4 }; + var byteValue = ValueConverter.ToVariant(bytes); + var numericValue = ValueConverter.ToVariant(numbers); + bytes[0] = 99; + numbers[0] = 99; + ValueConverter.ToClrObject(byteValue).ShouldBe(new byte[] { 1, 2 }); + ValueConverter.ToClrObject(numericValue).ShouldBe(new[] { 3, 4 }); + } + + [Fact] + public void ByteString_NullAndEmptyRemainDistinct() + { + ValueConverter.ToClrObject(Variant.From(default(ByteString))).ShouldBeNull(); + ValueConverter.ToClrObject(Variant.From(ByteString.Empty)).ShouldBe(Array.Empty()); + ValueConverter.ToClrObject(Variant.Null).ShouldBeNull(); + ValueConverter.ToClrObject(Variant.From(0)).ShouldBe(0); + } + + [Fact] + public void NullWrapperInputs_RemainNullAfterRetention() + { + ValueConverter.ToClrObject(ValueConverter.ToVariant(default(ByteString))).ShouldBeNull(); + ValueConverter.ToClrObject(ValueConverter.ToVariant(default(ArrayOf))).ShouldBeNull(); + ValueConverter.ToVariant(default(ArrayOf)).TypeInfo.ValueRank.ShouldBe(ValueRanks.OneDimension); + ValueConverter.ToClrObject(ValueConverter.ToVariant(Variant.From(default(ByteString)))).ShouldBeNull(); + } + + [Fact] + public void ReadOnlyWrappers_AreDetachedFromMutableBackingBuffers() + { + var bytes = new byte[] { 1, 2 }; + var numbers = new[] { 3, 4 }; + var retainedBytes = ValueConverter.ToVariant(new ByteString(bytes)); + var retainedNumbers = ValueConverter.ToVariant(new ArrayOf(numbers)); + bytes[0] = 99; + numbers[0] = 99; + ValueConverter.ToClrObject(retainedBytes).ShouldBe(new byte[] { 1, 2 }); + ValueConverter.ToClrObject(retainedNumbers).ShouldBe(new[] { 3, 4 }); + } + + [Fact] + public void RetainedByteStringArray_CopiesElementBuffersAndPreservesNullEmpty() + { + var bytes = new byte[] { 1, 2 }; + var value = ValueConverter.ToVariant(new ByteString[] { default, ByteString.Empty, new(bytes) }); + bytes[0] = 99; + var actual = ValueConverter.ToClrObject(value).ShouldBeOfType(); + actual[0].ShouldBeNull(); + actual[1].ShouldBeEmpty(); + actual[2].ShouldBe(new byte[] { 1, 2 }); + } + + [Fact] + public void ByteArrayAndByteString_KeepWireRank() + { + ValueConverter.ToVariant(new byte[] { 1 }).TypeInfo.BuiltInType.ShouldBe(BuiltInType.ByteString); + ValueConverter.ToVariant(new ArrayOf(new byte[] { 1 })).TypeInfo.BuiltInType.ShouldBe(BuiltInType.Byte); + ValueConverter.ToVariant(new ArrayOf(new byte[] { 1 })).TypeInfo.ValueRank.ShouldBe(ValueRanks.OneDimension); + } + + [Fact] + public void NodeIds_KeepOpaqueBytesAndLiteralStringIdentifiers() + { + var opaque = NodeId.Parse("ns=2;b=AQID"); + ValueConverter.ToClrObject(ValueConverter.ToVariant(opaque)).ShouldBe(opaque); + var literal = new NodeId("ns=2;s=literal", 4); + var result = ValueConverter.ToClrObject(ValueConverter.ToVariant(literal)).ShouldBeOfType(); + result.NamespaceIndex.ShouldBe((ushort)4); + result.TryGetValue(out string? identifier).ShouldBeTrue(); + identifier.ShouldBe("ns=2;s=literal"); + } + + [Fact] + public void UnsupportedShape_IsRejected() + { + Should.Throw(() => ValueConverter.ToVariant(new int[2, 2])); + Should.Throw(() => ValueConverter.ToVariant(new object())); + } +} diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Models/ModelConstructionTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Models/ModelConstructionTests.cs index a9f01b51..0a98d1a2 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Models/ModelConstructionTests.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Models/ModelConstructionTests.cs @@ -76,7 +76,7 @@ public class ModelConstructionTests args.NodeId.ShouldBe("ns=2;s=Temp"); args.Value.ShouldBe(value); - args.Value.Value.ShouldBe(42); + args.Value.WrappedValue.ShouldBe(Variant.From(42)); } /// Verifies that ConnectionStateChangedEventArgs constructs correctly with all properties. diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs index 57794c23..8c74f90c 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs @@ -197,9 +197,9 @@ public class OpcUaClientServiceTests : IDisposable _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken); - var result = await _service.ReadValueAsync(new NodeId("ns=2;s=MyNode"), TestContext.Current.CancellationToken); + var result = await _service.ReadValueAsync(NodeId.Parse("ns=2;s=MyNode"), TestContext.Current.CancellationToken); - result.Value.ShouldBe(42); + result.WrappedValue.ShouldBe(Variant.From(42)); session.ReadCount.ShouldBe(1); } @@ -210,7 +210,7 @@ public class OpcUaClientServiceTests : IDisposable public async Task ReadValueAsync_WhenDisconnected_Throws() { await Should.ThrowAsync(() => - _service.ReadValueAsync(new NodeId("ns=2;s=MyNode"))); + _service.ReadValueAsync(NodeId.Parse("ns=2;s=MyNode"))); } /// @@ -224,7 +224,7 @@ public class OpcUaClientServiceTests : IDisposable await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken); await Should.ThrowAsync(() => - _service.ReadValueAsync(new NodeId("ns=2;s=MyNode"))); + _service.ReadValueAsync(NodeId.Parse("ns=2;s=MyNode"))); } // --- Write tests --- @@ -243,7 +243,7 @@ public class OpcUaClientServiceTests : IDisposable _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken); - var result = await _service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), 42, TestContext.Current.CancellationToken); + var result = await _service.WriteValueAsync(NodeId.Parse("ns=2;s=MyNode"), 42, TestContext.Current.CancellationToken); result.ShouldBe(StatusCodes.Good); session.WriteCount.ShouldBe(1); @@ -262,7 +262,7 @@ public class OpcUaClientServiceTests : IDisposable _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken); - await _service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), "42", TestContext.Current.CancellationToken); + await _service.WriteValueAsync(NodeId.Parse("ns=2;s=MyNode"), "42", TestContext.Current.CancellationToken); session.WriteCount.ShouldBe(1); session.ReadCount.ShouldBe(1); // Read for type inference @@ -278,7 +278,7 @@ public class OpcUaClientServiceTests : IDisposable _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken); - await _service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), 42, TestContext.Current.CancellationToken); + await _service.WriteValueAsync(NodeId.Parse("ns=2;s=MyNode"), 42, TestContext.Current.CancellationToken); session.WriteCount.ShouldBe(1); session.ReadCount.ShouldBe(0); // No read for non-string values @@ -291,7 +291,7 @@ public class OpcUaClientServiceTests : IDisposable public async Task WriteValueAsync_WhenDisconnected_Throws() { await Should.ThrowAsync(() => - _service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), 42)); + _service.WriteValueAsync(NodeId.Parse("ns=2;s=MyNode"), 42)); } /// @@ -303,13 +303,13 @@ public class OpcUaClientServiceTests : IDisposable { var session = new FakeSessionAdapter { - ReadResponse = new DataValue(StatusCodes.BadNodeIdUnknown) // Bad status, null Value + ReadResponse = DataValue.FromStatusCode(StatusCodes.BadNodeIdUnknown) // Bad status, null Value }; _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken); var ex = await Should.ThrowAsync(() => - _service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), "42")); + _service.WriteValueAsync(NodeId.Parse("ns=2;s=MyNode"), "42")); ex.Message.ShouldContain("Cannot infer target type"); } @@ -323,17 +323,90 @@ public class OpcUaClientServiceTests : IDisposable { var session = new FakeSessionAdapter { - ReadResponse = new DataValue(StatusCodes.BadNotReadable) + ReadResponse = DataValue.FromStatusCode(StatusCodes.BadNotReadable) }; _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken); var ex = await Should.ThrowAsync(() => - _service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), "42")); + _service.WriteValueAsync(NodeId.Parse("ns=2;s=MyNode"), "42")); ex.Message.ShouldContain("ns=2;s=MyNode"); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task BrowseCancellation_ReleasesOwnedCursor_WithoutMaskingCancellation(bool releaseFails) + { + using var cts = new CancellationTokenSource(); + var session = new FakeSessionAdapter + { + BrowseResponse = [new ReferenceDescription { NodeId = new ExpandedNodeId("child", 2) }], + BrowseContinuationPoint = [7], + ThrowOnBrowseRelease = releaseFails, + BeforeBrowseNext = token => { cts.Cancel(); token.ThrowIfCancellationRequested(); return Task.CompletedTask; } + }; + _sessionFactory.EnqueueSession(session); + await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken); + await Should.ThrowAsync(() => _service.BrowseAsync(ct: cts.Token)); + var release = session.BrowseReleases.ShouldHaveSingleItem(); + release.Point.ShouldBe(new byte[] { 7 }); + release.Cancelled.ShouldBeFalse(); + } + + [Fact] + public async Task BrowseEmptyPage_ReleasesRemainingCursor() + { + var session = new FakeSessionAdapter { BrowseContinuationPoint = [8] }; + _sessionFactory.EnqueueSession(session); + await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken); + (await _service.BrowseAsync(ct: TestContext.Current.CancellationToken)).ShouldBeEmpty(); + session.BrowseReleases.ShouldHaveSingleItem().Point.ShouldBe(new byte[] { 8 }); + } + + [Fact] + public async Task ReadValue_PreservesSdkStatusTimestampsAndPicoseconds() + { + var source = new DateTime(2026, 11, 10, 12, 0, 0, DateTimeKind.Utc); + var value = new DataValue(Variant.From(12.5), StatusCodes.UncertainLastUsableValue, + source, source.AddSeconds(1), 12, 34); + var session = new FakeSessionAdapter { ReadResponse = value }; + _sessionFactory.EnqueueSession(session); + await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken); + var actual = await _service.ReadValueAsync(new NodeId("tag", 2), TestContext.Current.CancellationToken); + actual.ShouldBe(value); + actual.StatusCode.ShouldBe(StatusCodes.UncertainLastUsableValue); + actual.SourcePicoseconds.ShouldBe((ushort)12); + actual.ServerPicoseconds.ShouldBe((ushort)34); + } + + [Fact] + public async Task AlarmMapping_PreservesTimeEmptyTextAndRetainedEventId() + { + var session = new FakeSessionAdapter(); + _sessionFactory.EnqueueSession(session); + await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken); + await _service.SubscribeAlarmsAsync(ct: TestContext.Current.CancellationToken); + var timestamp = new DateTime(2026, 11, 10, 12, 0, 0, DateTimeKind.Utc); + var bytes = new byte[] { 1, 2, 3 }; + AlarmEventArgs? received = null; + _service.AlarmEvent += (_, e) => received = e; + var subscription = session.CreatedSubscriptions.Single(); + subscription.SimulateEvent(subscription.ActiveHandles.Single(), new EventFieldList + { + EventFields = [Variant.From(new ByteString(bytes)), Variant.From(ObjectTypeIds.AlarmConditionType), + Variant.From("source"), Variant.From((DateTimeUtc)timestamp), Variant.From(LocalizedText.Null), + Variant.From((ushort)321), Variant.From("condition"), Variant.From(true)] + }); + bytes[0] = 99; + received.ShouldNotBeNull(); + received.Time.ShouldBe(timestamp); + received.Message.ShouldBe(""); + received.EventId.ShouldBe(new byte[] { 1, 2, 3 }); + received.Severity.ShouldBe((ushort)321); + } + // --- Browse tests --- /// @@ -348,7 +421,7 @@ public class OpcUaClientServiceTests : IDisposable [ new ReferenceDescription { - NodeId = new ExpandedNodeId("ns=2;s=Child1"), + NodeId = ExpandedNodeId.Parse("ns=2;s=Child1"), DisplayName = new LocalizedText("Child1"), NodeClass = NodeClass.Variable } @@ -397,19 +470,19 @@ public class OpcUaClientServiceTests : IDisposable [ new ReferenceDescription { - NodeId = new ExpandedNodeId("ns=2;s=Folder1"), + NodeId = ExpandedNodeId.Parse("ns=2;s=Folder1"), DisplayName = new LocalizedText("Folder1"), NodeClass = NodeClass.Object }, new ReferenceDescription { - NodeId = new ExpandedNodeId("ns=2;s=Folder2"), + NodeId = ExpandedNodeId.Parse("ns=2;s=Folder2"), DisplayName = new LocalizedText("Folder2"), NodeClass = NodeClass.Object }, new ReferenceDescription { - NodeId = new ExpandedNodeId("ns=2;s=Tag"), + NodeId = ExpandedNodeId.Parse("ns=2;s=Tag"), DisplayName = new LocalizedText("Tag"), NodeClass = NodeClass.Variable } @@ -437,7 +510,7 @@ public class OpcUaClientServiceTests : IDisposable [ new ReferenceDescription { - NodeId = new ExpandedNodeId("ns=2;s=A"), + NodeId = ExpandedNodeId.Parse("ns=2;s=A"), DisplayName = new LocalizedText("A"), NodeClass = NodeClass.Variable } @@ -447,7 +520,7 @@ public class OpcUaClientServiceTests : IDisposable [ new ReferenceDescription { - NodeId = new ExpandedNodeId("ns=2;s=B"), + NodeId = ExpandedNodeId.Parse("ns=2;s=B"), DisplayName = new LocalizedText("B"), NodeClass = NodeClass.Variable } @@ -484,7 +557,7 @@ public class OpcUaClientServiceTests : IDisposable _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken); - await _service.SubscribeAsync(new NodeId("ns=2;s=MyNode"), 500, TestContext.Current.CancellationToken); + await _service.SubscribeAsync(NodeId.Parse("ns=2;s=MyNode"), 500, TestContext.Current.CancellationToken); session.CreatedSubscriptions.Count.ShouldBe(1); session.CreatedSubscriptions[0].AddDataChangeCount.ShouldBe(1); @@ -500,8 +573,8 @@ public class OpcUaClientServiceTests : IDisposable _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken); - await _service.SubscribeAsync(new NodeId("ns=2;s=MyNode"), ct: TestContext.Current.CancellationToken); - await _service.SubscribeAsync(new NodeId("ns=2;s=MyNode"), ct: TestContext.Current.CancellationToken); // duplicate + await _service.SubscribeAsync(NodeId.Parse("ns=2;s=MyNode"), ct: TestContext.Current.CancellationToken); + await _service.SubscribeAsync(NodeId.Parse("ns=2;s=MyNode"), ct: TestContext.Current.CancellationToken); // duplicate session.CreatedSubscriptions[0].AddDataChangeCount.ShouldBe(1); } @@ -520,7 +593,7 @@ public class OpcUaClientServiceTests : IDisposable DataChangedEventArgs? received = null; _service.DataChanged += (_, e) => received = e; - await _service.SubscribeAsync(new NodeId("ns=2;s=MyNode"), 500, TestContext.Current.CancellationToken); + await _service.SubscribeAsync(NodeId.Parse("ns=2;s=MyNode"), 500, TestContext.Current.CancellationToken); // Simulate data change var handle = fakeSub.ActiveHandles.First(); @@ -528,7 +601,7 @@ public class OpcUaClientServiceTests : IDisposable received.ShouldNotBeNull(); received!.NodeId.ShouldBe("ns=2;s=MyNode"); - received.Value.Value.ShouldBe(99); + received.Value.WrappedValue.ShouldBe(Variant.From(99)); } /// @@ -542,8 +615,8 @@ public class OpcUaClientServiceTests : IDisposable _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken); - await _service.SubscribeAsync(new NodeId("ns=2;s=MyNode"), ct: TestContext.Current.CancellationToken); - await _service.UnsubscribeAsync(new NodeId("ns=2;s=MyNode"), TestContext.Current.CancellationToken); + await _service.SubscribeAsync(NodeId.Parse("ns=2;s=MyNode"), ct: TestContext.Current.CancellationToken); + await _service.UnsubscribeAsync(NodeId.Parse("ns=2;s=MyNode"), TestContext.Current.CancellationToken); fakeSub.RemoveCount.ShouldBe(1); } @@ -558,7 +631,7 @@ public class OpcUaClientServiceTests : IDisposable _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken); - await _service.UnsubscribeAsync(new NodeId("ns=2;s=NotSubscribed"), TestContext.Current.CancellationToken); + await _service.UnsubscribeAsync(NodeId.Parse("ns=2;s=NotSubscribed"), TestContext.Current.CancellationToken); // Should not throw } @@ -569,7 +642,7 @@ public class OpcUaClientServiceTests : IDisposable public async Task SubscribeAsync_WhenDisconnected_Throws() { await Should.ThrowAsync(() => - _service.SubscribeAsync(new NodeId("ns=2;s=MyNode"))); + _service.SubscribeAsync(NodeId.Parse("ns=2;s=MyNode"))); } // --- Alarm subscription tests --- @@ -742,7 +815,7 @@ public class OpcUaClientServiceTests : IDisposable _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken); - var results = await _service.HistoryReadRawAsync(new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, ct: TestContext.Current.CancellationToken); + var results = await _service.HistoryReadRawAsync(NodeId.Parse("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, ct: TestContext.Current.CancellationToken); results.Count.ShouldBe(2); session.HistoryReadRawCount.ShouldBe(1); @@ -755,7 +828,7 @@ public class OpcUaClientServiceTests : IDisposable public async Task HistoryReadRawAsync_WhenDisconnected_Throws() { await Should.ThrowAsync(() => - _service.HistoryReadRawAsync(new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow)); + _service.HistoryReadRawAsync(NodeId.Parse("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow)); } /// @@ -769,7 +842,7 @@ public class OpcUaClientServiceTests : IDisposable await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken); await Should.ThrowAsync(() => - _service.HistoryReadRawAsync(new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow)); + _service.HistoryReadRawAsync(NodeId.Parse("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow)); } /// @@ -786,7 +859,7 @@ public class OpcUaClientServiceTests : IDisposable _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken); - var results = await _service.HistoryReadAggregateAsync(new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, AggregateType.Average, ct: TestContext.Current.CancellationToken); + var results = await _service.HistoryReadAggregateAsync(NodeId.Parse("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, AggregateType.Average, ct: TestContext.Current.CancellationToken); results.Count.ShouldBe(1); session.HistoryReadAggregateCount.ShouldBe(1); @@ -800,7 +873,7 @@ public class OpcUaClientServiceTests : IDisposable { await Should.ThrowAsync(() => _service.HistoryReadAggregateAsync( - new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, + NodeId.Parse("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, AggregateType.Average)); } @@ -816,7 +889,7 @@ public class OpcUaClientServiceTests : IDisposable await Should.ThrowAsync(() => _service.HistoryReadAggregateAsync( - new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, + NodeId.Parse("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, AggregateType.Average)); } @@ -840,7 +913,7 @@ public class OpcUaClientServiceTests : IDisposable return new DataValue(new Variant(["urn:server1", "urn:server2"]), StatusCodes.Good); if (nodeId == VariableIds.Server_ServerArray) return new DataValue(new Variant(["urn:server1"]), StatusCodes.Good); - return new DataValue(StatusCodes.BadNodeIdUnknown); + return DataValue.FromStatusCode(StatusCodes.BadNodeIdUnknown); } }; _sessionFactory.EnqueueSession(session); @@ -933,9 +1006,9 @@ public class OpcUaClientServiceTests : IDisposable ReadResponseFunc = nodeId => { if (nodeId == VariableIds.Server_ServerRedundancy_RedundancySupport) - return new DataValue(StatusCodes.BadNotReadable); // bad status, null Value + return DataValue.FromStatusCode(StatusCodes.BadNotReadable); // bad status, null Value if (nodeId == VariableIds.Server_ServiceLevel) - return new DataValue(StatusCodes.BadNotReadable); + return DataValue.FromStatusCode(StatusCodes.BadNotReadable); throw new ServiceResultException(StatusCodes.BadNodeIdUnknown); } }; @@ -1046,7 +1119,7 @@ public class OpcUaClientServiceTests : IDisposable var result = await _service.AcknowledgeAlarmAsync("ns=2;s=Cond", new byte[] { 1, 2 }, "acked", TestContext.Current.CancellationToken); StatusCode.IsBad(result).ShouldBeTrue(); - result.Code.ShouldBe(StatusCodes.BadConditionAlreadyEnabled); + result.Code.ShouldBe(StatusCodes.BadConditionAlreadyEnabled.Code); } /// @@ -1105,7 +1178,7 @@ public class OpcUaClientServiceTests : IDisposable var result = await _service.ConfirmAlarmAsync("ns=2;s=Cond", new byte[] { 1, 2 }, "confirmed", TestContext.Current.CancellationToken); StatusCode.IsBad(result).ShouldBeTrue(); - result.Code.ShouldBe(StatusCodes.BadConditionAlreadyEnabled); + result.Code.ShouldBe(StatusCodes.BadConditionAlreadyEnabled.Code); } // --- ShelveAlarm tests --- @@ -1186,7 +1259,7 @@ public class OpcUaClientServiceTests : IDisposable var result = await _service.ShelveAlarmAsync("ns=2;s=Cond", ShelveKind.OneShot, ct: TestContext.Current.CancellationToken); StatusCode.IsBad(result).ShouldBeTrue(); - result.Code.ShouldBe(StatusCodes.BadConditionAlreadyShelved); + result.Code.ShouldBe(StatusCodes.BadConditionAlreadyShelved.Code); } // --- Enable / Disable tests (H4 client path) --- @@ -1249,7 +1322,7 @@ public class OpcUaClientServiceTests : IDisposable var result = await _service.EnableAsync("ns=2;s=Cond", TestContext.Current.CancellationToken); StatusCode.IsBad(result).ShouldBeTrue(); - result.Code.ShouldBe(StatusCodes.BadConditionAlreadyEnabled); + result.Code.ShouldBe(StatusCodes.BadConditionAlreadyEnabled.Code); } /// @@ -1270,7 +1343,7 @@ public class OpcUaClientServiceTests : IDisposable var result = await _service.DisableAsync("ns=2;s=Cond", TestContext.Current.CancellationToken); StatusCode.IsBad(result).ShouldBeTrue(); - result.Code.ShouldBe(StatusCodes.BadConditionAlreadyDisabled); + result.Code.ShouldBe(StatusCodes.BadConditionAlreadyDisabled.Code); } // --- Missing standard alarm fields (#598: the Galaxy-era supplemental reads are gone) --- @@ -1484,7 +1557,7 @@ public class OpcUaClientServiceTests : IDisposable var gate = new TaskCompletionSource(); session1.CreateSubscriptionGate = gate; - var subscribe = _service.SubscribeAsync(new NodeId("ns=2;s=Racer"), 1000, TestContext.Current.CancellationToken); + var subscribe = _service.SubscribeAsync(NodeId.Parse("ns=2;s=Racer"), 1000, TestContext.Current.CancellationToken); await session1.CreateSubscriptionEntered.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); session1.SimulateKeepAlive(false); @@ -1499,7 +1572,7 @@ public class OpcUaClientServiceTests : IDisposable replayed.AddDataChangeCount.ShouldBe(1); // And it is the live subscription: unsubscribing removes it from session2's subscription, not session1's. - await _service.UnsubscribeAsync(new NodeId("ns=2;s=Racer"), TestContext.Current.CancellationToken); + await _service.UnsubscribeAsync(NodeId.Parse("ns=2;s=Racer"), TestContext.Current.CancellationToken); replayed.RemoveCount.ShouldBe(1); replayed.ActiveHandles.ShouldBeEmpty(); } @@ -1548,7 +1621,7 @@ public class OpcUaClientServiceTests : IDisposable var tasks = new List(); for (var i = 0; i < 50; i++) { - var nodeId = new NodeId($"ns=2;s=Node{i}"); + var nodeId = NodeId.Parse($"ns=2;s=Node{i}"); tasks.Add(Task.Run(async () => { await _service.SubscribeAsync(nodeId); @@ -1560,6 +1633,55 @@ public class OpcUaClientServiceTests : IDisposable await Should.NotThrowAsync(() => Task.WhenAll(tasks)); } + [Fact] + public async Task QueuedCallbacks_AfterDisposal_AreIgnored() + { + var session = new FakeSessionAdapter(); + _sessionFactory.EnqueueSession(session); + await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken); + await _service.SubscribeAsync(new NodeId("tag", 2), ct: TestContext.Current.CancellationToken); + await _service.SubscribeAlarmsAsync(ct: TestContext.Current.CancellationToken); + var data = session.CreatedSubscriptions[0]; + var alarm = session.CreatedSubscriptions[1]; + var queuedData = data.CaptureDataCallback(data.ActiveHandles.Single()); + var queuedAlarm = alarm.CaptureEventCallback(alarm.ActiveHandles.Single()); + var dataCount = 0; + var alarmCount = 0; + _service.DataChanged += (_, _) => dataCount++; + _service.AlarmEvent += (_, _) => alarmCount++; + queuedData("ns=2;s=tag", new DataValue(Variant.From(1))); + queuedAlarm(new EventFieldList { EventFields = [Variant.From(ByteString.Empty)] }); + dataCount.ShouldBe(1); + alarmCount.ShouldBe(1); + _service.Dispose(); + queuedData("ns=2;s=tag", new DataValue(Variant.From(2))); + queuedAlarm(new EventFieldList { EventFields = [Variant.From(ByteString.Empty)] }); + dataCount.ShouldBe(1); + alarmCount.ShouldBe(1); + } + + [Fact] + public async Task QueuedCallbacks_FromPreviousSession_AreIgnoredAfterFailover() + { + var first = new FakeSessionAdapter(); + var second = new FakeSessionAdapter(); + _sessionFactory.EnqueueSession(first); + _sessionFactory.EnqueueSession(second); + await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken); + await _service.SubscribeAsync(new NodeId("tag", 2), ct: TestContext.Current.CancellationToken); + var firstSubscription = first.CreatedSubscriptions.Single(); + var queued = firstSubscription.CaptureDataCallback(firstSubscription.ActiveHandles.Single()); + var received = new List(); + _service.DataChanged += (_, e) => received.Add(e.Value); + first.SimulateKeepAlive(false); + await _service.LastFailover!.WaitAsync(TestContext.Current.CancellationToken); + queued("ns=2;s=tag", new DataValue(Variant.From(1))); + received.ShouldBeEmpty(); + var current = second.CreatedSubscriptions.Single(); + current.SimulateDataChange(current.ActiveHandles.Single(), new DataValue(Variant.From(2))); + received.ShouldHaveSingleItem().WrappedValue.ShouldBe(Variant.From(2)); + } + // --- Dispose tests --- /// @@ -1598,7 +1720,7 @@ public class OpcUaClientServiceTests : IDisposable await Should.ThrowAsync(() => _service.ConnectAsync(ValidSettings())); await Should.ThrowAsync(() => - _service.ReadValueAsync(new NodeId("ns=2;s=X"))); + _service.ReadValueAsync(NodeId.Parse("ns=2;s=X"))); } // --- Factory tests --- @@ -1643,7 +1765,7 @@ public class OpcUaClientServiceTests : IDisposable await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken); // Subscribe data (uses dataFakeSub from session.NextSubscription). - await _service.SubscribeAsync(new NodeId("ns=2;s=Node1"), ct: TestContext.Current.CancellationToken); + await _service.SubscribeAsync(NodeId.Parse("ns=2;s=Node1"), ct: TestContext.Current.CancellationToken); // Now set NextSubscription on the session so SubscribeAlarmsAsync gets the alarm fake. session.NextSubscription = alarmFakeSub; diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/Fakes/FakeOpcUaClientService.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/Fakes/FakeOpcUaClientService.cs index a44d7a08..1142d3d2 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/Fakes/FakeOpcUaClientService.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/Fakes/FakeOpcUaClientService.cs @@ -191,7 +191,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService LastBrowseParentNodeId = parentNodeId; if (BrowseException != null) throw BrowseException; - if (parentNodeId != null && BrowseResultsByParent.TryGetValue(parentNodeId.ToString(), out var perParent)) + if (parentNodeId != null && BrowseResultsByParent.TryGetValue(parentNodeId.Value.ToString(), out var perParent)) return Task.FromResult(perParent); return Task.FromResult(BrowseResults); diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/ReadWriteViewModelTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/ReadWriteViewModelTests.cs index ff46d4e6..8363066c 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/ReadWriteViewModelTests.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/ReadWriteViewModelTests.cs @@ -23,6 +23,26 @@ public class ReadWriteViewModelTests _vm = new ReadWriteViewModel(_service, dispatcher); } + [Fact] + public async Task ReadCommand_FormatsSdkArraysByteStringsAndEmptyText() + { + _vm.IsConnected = true; + _vm.SelectedNodeId = "ns=2;s=SomeNode"; + foreach (var (value, expected) in new (Variant Value, string Expected)[] + { + (Variant.From(new int[] { 1, 2 }.ToArrayOf()), "[1,2]"), + (Variant.From(ArrayOf.Empty), "[]"), + (Variant.From(ByteString.From(new byte[] { 3, 4 })), "[3,4]"), + (Variant.From(LocalizedText.Null), ""), + (Variant.Null, "(null)") + }) + { + _service.ReadResult = new DataValue(value, StatusCodes.Good); + await _vm.ReadCommand.ExecuteAsync(null); + _vm.CurrentValue.ShouldBe(expected); + } + } + /// Verifies that the read command cannot execute when disconnected. [Fact] public void ReadCommand_CannotExecute_WhenDisconnected()