Document client stack XML docs progress

This commit is contained in:
Joseph Doherty
2026-04-01 08:58:17 -04:00
parent b2be438d33
commit 5c89a44255
27 changed files with 14809 additions and 28 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -17,30 +17,56 @@ public abstract class CommandBase : ICommand
private readonly IOpcUaClientServiceFactory _factory;
/// <summary>
/// Initializes a CLI command with the shared OPC UA client factory used to create per-command sessions.
/// </summary>
/// <param name="factory">The factory that creates the shared client service for the command execution.</param>
protected CommandBase(IOpcUaClientServiceFactory factory)
{
_factory = factory;
}
/// <summary>
/// Gets the primary OPC UA endpoint URL the command should connect to.
/// </summary>
[CommandOption("url", 'u', Description = "OPC UA server endpoint URL", IsRequired = true)]
public string Url { get; init; } = default!;
/// <summary>
/// Gets the optional username used for authenticated OPC UA sessions.
/// </summary>
[CommandOption("username", 'U', Description = "Username for authentication")]
public string? Username { get; init; }
/// <summary>
/// Gets the optional password paired with <see cref="Username" /> for authenticated OPC UA sessions.
/// </summary>
[CommandOption("password", 'P', Description = "Password for authentication")]
public string? Password { get; init; }
/// <summary>
/// Gets the transport security mode requested by the operator for this CLI session.
/// </summary>
[CommandOption("security", 'S',
Description = "Transport security: none, sign, encrypt, signandencrypt (default: none)")]
public string Security { get; init; } = "none";
/// <summary>
/// Gets the optional comma-separated failover endpoints that should be tried if the primary endpoint is unavailable.
/// </summary>
[CommandOption("failover-urls", 'F', Description = "Comma-separated failover endpoint URLs for redundancy")]
public string? FailoverUrls { get; init; }
/// <summary>
/// Gets a value indicating whether verbose Serilog output should be enabled for troubleshooting.
/// </summary>
[CommandOption("verbose", Description = "Enable verbose/debug logging")]
public bool Verbose { get; init; }
/// <summary>
/// Executes the command-specific workflow against the configured OPC UA endpoint.
/// </summary>
/// <param name="console">The CLI console used for output and cancellation handling.</param>
public abstract ValueTask ExecuteAsync(IConsole console);
/// <summary>
@@ -70,6 +96,7 @@ public abstract class CommandBase : ICommand
/// Creates a new <see cref="IOpcUaClientService" />, connects it using the common options,
/// and returns both the service and the connection info.
/// </summary>
/// <param name="ct">The cancellation token that aborts connection setup for the command.</param>
protected async Task<(IOpcUaClientService Service, ConnectionInfo Info)> CreateServiceAndConnectAsync(
CancellationToken ct)
{
@@ -94,4 +121,4 @@ public abstract class CommandBase : ICommand
Log.Logger = config.CreateLogger();
}
}
}
@@ -8,19 +8,36 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
[Command("alarms", Description = "Subscribe to alarm events")]
public class AlarmsCommand : CommandBase
{
/// <summary>
/// Creates the alarm-monitoring command used to stream OPC UA condition events to the terminal.
/// </summary>
/// <param name="factory">The factory that creates the shared client service for the command run.</param>
public AlarmsCommand(IOpcUaClientServiceFactory factory) : base(factory)
{
}
/// <summary>
/// Gets the optional source node whose alarm events should be monitored instead of the server root.
/// </summary>
[CommandOption("node", 'n', Description = "Node ID to monitor for events (default: Server node)")]
public string? NodeId { get; init; }
/// <summary>
/// Gets the publishing interval, in milliseconds, for the alarm subscription.
/// </summary>
[CommandOption("interval", 'i', Description = "Publishing interval in milliseconds")]
public int Interval { get; init; } = 1000;
/// <summary>
/// Gets a value indicating whether retained alarm conditions should be refreshed immediately after subscribing.
/// </summary>
[CommandOption("refresh", Description = "Request a ConditionRefresh after subscribing")]
public bool Refresh { get; init; }
/// <summary>
/// Connects to the server, subscribes to alarm events, and streams operator-facing alarm state changes to the console.
/// </summary>
/// <param name="console">The CLI console used for output and cancellation handling.</param>
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -83,4 +100,4 @@ public class AlarmsCommand : CommandBase
}
}
}
}
}
@@ -9,19 +9,36 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
[Command("browse", Description = "Browse the OPC UA address space")]
public class BrowseCommand : CommandBase
{
/// <summary>
/// Creates the browse command used to inspect the server address space from the terminal.
/// </summary>
/// <param name="factory">The factory that creates the shared client service for the command run.</param>
public BrowseCommand(IOpcUaClientServiceFactory factory) : base(factory)
{
}
/// <summary>
/// Gets the optional starting node for the browse, defaulting to the Objects folder.
/// </summary>
[CommandOption("node", 'n', Description = "Node ID to browse (default: Objects folder)")]
public string? NodeId { get; init; }
/// <summary>
/// Gets the maximum browse depth the command should traverse.
/// </summary>
[CommandOption("depth", 'd', Description = "Maximum browse depth")]
public int Depth { get; init; } = 1;
/// <summary>
/// Gets a value indicating whether child nodes should be traversed recursively.
/// </summary>
[CommandOption("recursive", 'r', Description = "Browse recursively (uses --depth as max depth)")]
public bool Recursive { get; init; }
/// <summary>
/// Connects to the server and prints a tree view of the requested address-space branch.
/// </summary>
/// <param name="console">The CLI console used for output and cancellation handling.</param>
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -77,4 +94,4 @@ public class BrowseCommand : CommandBase
}
}
}
}
}
@@ -7,10 +7,18 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
[Command("connect", Description = "Test connection to an OPC UA server")]
public class ConnectCommand : CommandBase
{
/// <summary>
/// Creates the connectivity test command used to verify endpoint reachability and negotiated security settings.
/// </summary>
/// <param name="factory">The factory that creates the shared client service for the command run.</param>
public ConnectCommand(IOpcUaClientServiceFactory factory) : base(factory)
{
}
/// <summary>
/// Connects to the server and prints the negotiated endpoint details for operator verification.
/// </summary>
/// <param name="console">The CLI console used for output and cancellation handling.</param>
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -34,4 +42,4 @@ public class ConnectCommand : CommandBase
}
}
}
}
}
@@ -10,28 +10,54 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
[Command("historyread", Description = "Read historical data from a node")]
public class HistoryReadCommand : CommandBase
{
/// <summary>
/// Creates the historical-data command used to inspect raw or aggregate history for a node.
/// </summary>
/// <param name="factory">The factory that creates the shared client service for the command run.</param>
public HistoryReadCommand(IOpcUaClientServiceFactory factory) : base(factory)
{
}
/// <summary>
/// Gets the historized node to query.
/// </summary>
[CommandOption("node", 'n', Description = "Node ID (e.g. ns=2;s=MyNode)", IsRequired = true)]
public string NodeId { get; init; } = default!;
/// <summary>
/// Gets the optional history start time string supplied by the operator.
/// </summary>
[CommandOption("start", Description = "Start time (ISO 8601 or date string, default: 24 hours ago)")]
public string? StartTime { get; init; }
/// <summary>
/// Gets the optional history end time string supplied by the operator.
/// </summary>
[CommandOption("end", Description = "End time (ISO 8601 or date string, default: now)")]
public string? EndTime { get; init; }
/// <summary>
/// Gets the maximum number of raw values the command should print.
/// </summary>
[CommandOption("max", Description = "Maximum number of values to return")]
public int MaxValues { get; init; } = 1000;
/// <summary>
/// Gets the optional aggregate name used when the operator wants processed history instead of raw samples.
/// </summary>
[CommandOption("aggregate", Description = "Aggregate function: Average, Minimum, Maximum, Count, Start, End")]
public string? Aggregate { get; init; }
/// <summary>
/// Gets the aggregate bucket interval, in milliseconds, for processed history reads.
/// </summary>
[CommandOption("interval", Description = "Processing interval in milliseconds for aggregates")]
public double IntervalMs { get; init; } = 3600000;
/// <summary>
/// Connects to the server and prints raw or processed historical values for the requested node.
/// </summary>
/// <param name="console">The CLI console used for output and cancellation handling.</param>
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -105,4 +131,4 @@ public class HistoryReadCommand : CommandBase
$"Unknown aggregate: '{name}'. Supported: Average, Minimum, Maximum, Count, Start, End")
};
}
}
}
@@ -8,13 +8,24 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
[Command("read", Description = "Read a value from a node")]
public class ReadCommand : CommandBase
{
/// <summary>
/// Creates the point-read command used to inspect the current value of a node.
/// </summary>
/// <param name="factory">The factory that creates the shared client service for the command run.</param>
public ReadCommand(IOpcUaClientServiceFactory factory) : base(factory)
{
}
/// <summary>
/// Gets the node whose current value should be read.
/// </summary>
[CommandOption("node", 'n', Description = "Node ID (e.g. ns=2;s=MyNode)", IsRequired = true)]
public string NodeId { get; init; } = default!;
/// <summary>
/// Connects to the server and prints the current value, status, and timestamps for the requested node.
/// </summary>
/// <param name="console">The CLI console used for output and cancellation handling.</param>
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -42,4 +53,4 @@ public class ReadCommand : CommandBase
}
}
}
}
}
@@ -7,10 +7,18 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
[Command("redundancy", Description = "Read redundancy state from an OPC UA server")]
public class RedundancyCommand : CommandBase
{
/// <summary>
/// Creates the redundancy-inspection command used to display service-level and partner-server status.
/// </summary>
/// <param name="factory">The factory that creates the shared client service for the command run.</param>
public RedundancyCommand(IOpcUaClientServiceFactory factory) : base(factory)
{
}
/// <summary>
/// Connects to the server and prints redundancy mode, service level, and partner-server identity data.
/// </summary>
/// <param name="console">The CLI console used for output and cancellation handling.</param>
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -42,4 +50,4 @@ public class RedundancyCommand : CommandBase
}
}
}
}
}
@@ -8,16 +8,30 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
[Command("subscribe", Description = "Monitor a node for value changes")]
public class SubscribeCommand : CommandBase
{
/// <summary>
/// Creates the live-data subscription command used to watch runtime value changes from the terminal.
/// </summary>
/// <param name="factory">The factory that creates the shared client service for the command run.</param>
public SubscribeCommand(IOpcUaClientServiceFactory factory) : base(factory)
{
}
/// <summary>
/// Gets the node whose live value changes should be monitored.
/// </summary>
[CommandOption("node", 'n', Description = "Node ID to monitor", IsRequired = true)]
public string NodeId { get; init; } = default!;
/// <summary>
/// Gets the sampling interval, in milliseconds, for the monitored item.
/// </summary>
[CommandOption("interval", 'i', Description = "Sampling interval in milliseconds")]
public int Interval { get; init; } = 1000;
/// <summary>
/// Connects to the server and streams live data-change notifications for the requested node.
/// </summary>
/// <param name="console">The CLI console used for output and cancellation handling.</param>
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -61,4 +75,4 @@ public class SubscribeCommand : CommandBase
}
}
}
}
}
@@ -10,16 +10,30 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
[Command("write", Description = "Write a value to a node")]
public class WriteCommand : CommandBase
{
/// <summary>
/// Creates the write command used to update a node from the terminal.
/// </summary>
/// <param name="factory">The factory that creates the shared client service for the command run.</param>
public WriteCommand(IOpcUaClientServiceFactory factory) : base(factory)
{
}
/// <summary>
/// Gets the node whose value should be updated.
/// </summary>
[CommandOption("node", 'n', Description = "Node ID (e.g. ns=2;s=MyNode)", IsRequired = true)]
public string NodeId { get; init; } = default!;
/// <summary>
/// Gets the raw operator-entered value that will be converted to the node's runtime type before writing.
/// </summary>
[CommandOption("value", 'v', Description = "Value to write", IsRequired = true)]
public string Value { get; init; } = default!;
/// <summary>
/// Connects to the server, converts the supplied value to the node's current data type, and issues the write.
/// </summary>
/// <param name="console">The CLI console used for output and cancellation handling.</param>
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -51,4 +65,4 @@ public class WriteCommand : CommandBase
}
}
}
}
}
@@ -12,20 +12,40 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
private static readonly ILogger Logger = Log.ForContext<DefaultSessionAdapter>();
private readonly Session _session;
/// <summary>
/// Wraps a live OPC UA session so the shared client can issue runtime operations through a testable adapter surface.
/// </summary>
/// <param name="session">The connected OPC UA session used for browsing, reads, writes, history, and subscriptions.</param>
public DefaultSessionAdapter(Session session)
{
_session = session;
}
/// <inheritdoc />
public bool Connected => _session.Connected;
/// <inheritdoc />
public string SessionId => _session.SessionId?.ToString() ?? string.Empty;
/// <inheritdoc />
public string SessionName => _session.SessionName ?? string.Empty;
/// <inheritdoc />
public string EndpointUrl => _session.Endpoint?.EndpointUrl ?? string.Empty;
/// <inheritdoc />
public string ServerName => _session.Endpoint?.Server?.ApplicationName?.Text ?? string.Empty;
/// <inheritdoc />
public string SecurityMode => _session.Endpoint?.SecurityMode.ToString() ?? string.Empty;
/// <inheritdoc />
public string SecurityPolicyUri => _session.Endpoint?.SecurityPolicyUri ?? string.Empty;
/// <inheritdoc />
public NamespaceTable NamespaceUris => _session.NamespaceUris;
/// <inheritdoc />
public void RegisterKeepAliveHandler(Action<bool> callback)
{
_session.KeepAlive += (_, e) =>
@@ -35,11 +55,13 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
};
}
/// <inheritdoc />
public async Task<DataValue> ReadValueAsync(NodeId nodeId, CancellationToken ct)
{
return await _session.ReadValueAsync(nodeId, ct);
}
/// <inheritdoc />
public async Task<StatusCode> WriteValueAsync(NodeId nodeId, DataValue value, CancellationToken ct)
{
var writeValue = new WriteValue
@@ -54,6 +76,7 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
return response.Results[0];
}
/// <inheritdoc />
public async Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseAsync(
NodeId nodeId, uint nodeClassMask, CancellationToken ct)
{
@@ -70,6 +93,7 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
return (continuationPoint, references ?? []);
}
/// <inheritdoc />
public async Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseNextAsync(
byte[] continuationPoint, CancellationToken ct)
{
@@ -77,6 +101,7 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
return (nextCp, nextRefs ?? []);
}
/// <inheritdoc />
public async Task<bool> HasChildrenAsync(NodeId nodeId, CancellationToken ct)
{
var (_, _, references) = await _session.BrowseAsync(
@@ -92,6 +117,7 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
return references != null && references.Count > 0;
}
/// <inheritdoc />
public async Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(
NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues, CancellationToken ct)
{
@@ -142,6 +168,7 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
return allValues;
}
/// <inheritdoc />
public async Task<IReadOnlyList<DataValue>> HistoryReadAggregateAsync(
NodeId nodeId, DateTime startTime, DateTime endTime, NodeId aggregateId, double intervalMs,
CancellationToken ct)
@@ -182,6 +209,7 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
return allValues;
}
/// <inheritdoc />
public async Task<ISubscriptionAdapter> CreateSubscriptionAsync(int publishingIntervalMs, CancellationToken ct)
{
var subscription = new Subscription(_session.DefaultSubscription)
@@ -196,6 +224,7 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
return new DefaultSubscriptionAdapter(subscription);
}
/// <inheritdoc />
public async Task CloseAsync(CancellationToken ct)
{
try
@@ -208,6 +237,9 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
}
}
/// <summary>
/// Releases the wrapped OPC UA session when the shared client shuts down or swaps endpoints during failover.
/// </summary>
public void Dispose()
{
try
@@ -221,6 +253,7 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
_session.Dispose();
}
/// <inheritdoc />
public async Task<IList<object>?> CallMethodAsync(NodeId objectId, NodeId methodId, object[] inputArguments,
CancellationToken ct = default)
{
@@ -243,4 +276,4 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
return callResult.OutputArguments?.Select(v => v.Value).ToList();
}
}
}
@@ -13,13 +13,19 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
private readonly Dictionary<uint, MonitoredItem> _monitoredItems = new();
private readonly Subscription _subscription;
/// <summary>
/// Wraps a live OPC UA subscription so client code can manage monitored items through a testable abstraction.
/// </summary>
/// <param name="subscription">The underlying OPC UA subscription that owns monitored items for this client workflow.</param>
public DefaultSubscriptionAdapter(Subscription subscription)
{
_subscription = subscription;
}
/// <inheritdoc />
public uint SubscriptionId => _subscription.Id;
/// <inheritdoc />
public async Task<uint> AddDataChangeMonitoredItemAsync(
NodeId nodeId, int samplingIntervalMs, Action<string, DataValue> onDataChange, CancellationToken ct)
{
@@ -46,6 +52,7 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
return handle;
}
/// <inheritdoc />
public async Task RemoveMonitoredItemAsync(uint clientHandle, CancellationToken ct)
{
if (!_monitoredItems.TryGetValue(clientHandle, out var item))
@@ -58,6 +65,7 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
Logger.Debug("Removed monitored item handle={Handle}", clientHandle);
}
/// <inheritdoc />
public async Task<uint> AddEventMonitoredItemAsync(
NodeId nodeId, int samplingIntervalMs, EventFilter filter, Action<EventFieldList> onEvent, CancellationToken ct)
{
@@ -86,11 +94,13 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
return handle;
}
/// <inheritdoc />
public async Task ConditionRefreshAsync(CancellationToken ct)
{
await _subscription.ConditionRefreshAsync(ct);
}
/// <inheritdoc />
public async Task DeleteAsync(CancellationToken ct)
{
try
@@ -105,6 +115,9 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
_monitoredItems.Clear();
}
/// <summary>
/// Releases the wrapped OPC UA subscription and clears tracked monitored items held by the adapter.
/// </summary>
public void Dispose()
{
try
@@ -117,4 +130,4 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
_monitoredItems.Clear();
}
}
}
@@ -7,59 +7,134 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters;
/// </summary>
internal interface ISessionAdapter : IDisposable
{
/// <summary>
/// Gets a value indicating whether the underlying OPC UA session is currently usable for client operations.
/// </summary>
bool Connected { get; }
/// <summary>
/// Gets the server-assigned session identifier for diagnostics and failover reporting.
/// </summary>
string SessionId { get; }
/// <summary>
/// Gets the friendly session name presented to the OPC UA server.
/// </summary>
string SessionName { get; }
/// <summary>
/// Gets the active endpoint URL that this adapter is connected to.
/// </summary>
string EndpointUrl { get; }
/// <summary>
/// Gets the server name reported by the connected OPC UA endpoint.
/// </summary>
string ServerName { get; }
/// <summary>
/// Gets the negotiated OPC UA message security mode for the session.
/// </summary>
string SecurityMode { get; }
/// <summary>
/// Gets the negotiated OPC UA security policy URI for the session.
/// </summary>
string SecurityPolicyUri { get; }
/// <summary>
/// Gets the namespace table used to resolve expanded node identifiers returned by browse operations.
/// </summary>
NamespaceTable NamespaceUris { get; }
/// <summary>
/// Registers a keep-alive callback. The callback receives true when the session is healthy, false on failure.
/// </summary>
/// <param name="callback">The callback used by higher-level clients to trigger reconnect or failover behavior.</param>
void RegisterKeepAliveHandler(Action<bool> callback);
/// <summary>
/// Reads the current value for a node from the connected OPC UA server.
/// </summary>
/// <param name="nodeId">The node whose current runtime value should be read.</param>
/// <param name="ct">The cancellation token that aborts the server read if the client cancels the request.</param>
Task<DataValue> ReadValueAsync(NodeId nodeId, CancellationToken ct = default);
/// <summary>
/// Writes a typed value to a node on the connected OPC UA server.
/// </summary>
/// <param name="nodeId">The node whose value should be updated.</param>
/// <param name="value">The typed OPC UA data value to write to the server.</param>
/// <param name="ct">The cancellation token that aborts the write if the client cancels the request.</param>
Task<StatusCode> WriteValueAsync(NodeId nodeId, DataValue value, CancellationToken ct = default);
/// <summary>
/// Browses forward hierarchical references from the given node.
/// Returns (continuationPoint, references).
/// </summary>
/// <param name="nodeId">The starting node for the hierarchical browse.</param>
/// <param name="nodeClassMask">The node classes that should be returned to the caller.</param>
/// <param name="ct">The cancellation token that aborts the browse request.</param>
Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseAsync(
NodeId nodeId, uint nodeClassMask = 0, CancellationToken ct = default);
/// <summary>
/// Continues a browse from a continuation point.
/// </summary>
/// <param name="continuationPoint">The continuation token returned by a prior browse result page.</param>
/// <param name="ct">The cancellation token that aborts the browse-next request.</param>
Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseNextAsync(
byte[] continuationPoint, CancellationToken ct = default);
/// <summary>
/// Checks whether a node has any forward hierarchical child references.
/// </summary>
/// <param name="nodeId">The node to inspect for child objects or variables.</param>
/// <param name="ct">The cancellation token that aborts the child lookup.</param>
Task<bool> HasChildrenAsync(NodeId nodeId, CancellationToken ct = default);
/// <summary>
/// Reads raw historical data.
/// </summary>
/// <param name="nodeId">The historized node whose raw samples should be retrieved.</param>
/// <param name="startTime">The inclusive start of the requested history window.</param>
/// <param name="endTime">The inclusive end of the requested history window.</param>
/// <param name="maxValues">The maximum number of raw samples to return to the client.</param>
/// <param name="ct">The cancellation token that aborts the history read.</param>
Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
int maxValues, CancellationToken ct = default);
/// <summary>
/// Reads processed/aggregate historical data.
/// </summary>
/// <param name="nodeId">The historized node whose processed values should be retrieved.</param>
/// <param name="startTime">The inclusive start of the requested processed-history window.</param>
/// <param name="endTime">The inclusive end of the requested processed-history window.</param>
/// <param name="aggregateId">The OPC UA aggregate function to evaluate over the history window.</param>
/// <param name="intervalMs">The processing interval, in milliseconds, for each aggregate bucket.</param>
/// <param name="ct">The cancellation token that aborts the aggregate history read.</param>
Task<IReadOnlyList<DataValue>> HistoryReadAggregateAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
NodeId aggregateId, double intervalMs, CancellationToken ct = default);
/// <summary>
/// Creates a subscription adapter for this session.
/// </summary>
/// <param name="publishingIntervalMs">The requested publishing interval for monitored items on the new subscription.</param>
/// <param name="ct">The cancellation token that aborts subscription creation.</param>
Task<ISubscriptionAdapter> CreateSubscriptionAsync(int publishingIntervalMs, CancellationToken ct = default);
/// <summary>
/// Calls an OPC UA method node with the provided input arguments.
/// </summary>
/// <param name="objectId">The object node that owns the target method.</param>
/// <param name="methodId">The method node to invoke.</param>
/// <param name="inputArguments">The ordered input arguments supplied to the server method call.</param>
/// <param name="ct">The cancellation token that aborts the method invocation.</param>
Task<IList<object>?> CallMethodAsync(NodeId objectId, NodeId methodId, object[] inputArguments, CancellationToken ct = default);
/// <summary>
/// Closes the underlying session gracefully before the adapter is disposed or replaced during failover.
/// </summary>
/// <param name="ct">The cancellation token that aborts the close request.</param>
Task CloseAsync(CancellationToken ct = default);
}
}
@@ -7,6 +7,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters;
/// </summary>
internal interface ISubscriptionAdapter : IDisposable
{
/// <summary>
/// Gets the server-assigned subscription identifier for diagnostics and reconnect workflows.
/// </summary>
uint SubscriptionId { get; }
/// <summary>
@@ -23,6 +26,8 @@ internal interface ISubscriptionAdapter : IDisposable
/// <summary>
/// Removes a previously added monitored item by its client handle.
/// </summary>
/// <param name="clientHandle">The client handle returned when the monitored item was created.</param>
/// <param name="ct">The cancellation token that aborts the monitored-item removal.</param>
Task RemoveMonitoredItemAsync(uint clientHandle, CancellationToken ct = default);
/// <summary>
@@ -40,10 +45,12 @@ internal interface ISubscriptionAdapter : IDisposable
/// <summary>
/// Requests a condition refresh for this subscription.
/// </summary>
/// <param name="ct">The cancellation token that aborts the condition refresh request.</param>
Task ConditionRefreshAsync(CancellationToken ct = default);
/// <summary>
/// Removes all monitored items and deletes the subscription.
/// </summary>
/// <param name="ct">The cancellation token that aborts subscription deletion.</param>
Task DeleteAsync(CancellationToken ct = default);
}
}
@@ -9,33 +9,136 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.Shared;
/// </summary>
public interface IOpcUaClientService : IDisposable
{
/// <summary>
/// Gets a value indicating whether the client is currently connected to an OPC UA endpoint.
/// </summary>
bool IsConnected { get; }
/// <summary>
/// Gets the current connection metadata shown to CLI and UI operators after a successful connect or failover.
/// </summary>
ConnectionInfo? CurrentConnectionInfo { get; }
/// <summary>
/// Connects the client to the configured OPC UA endpoint set, including failover-capable endpoints when provided.
/// </summary>
/// <param name="settings">The endpoint, security, and authentication settings used to establish the session.</param>
/// <param name="ct">The cancellation token that aborts the connect workflow.</param>
Task<ConnectionInfo> ConnectAsync(ConnectionSettings settings, CancellationToken ct = default);
/// <summary>
/// Disconnects from the active OPC UA endpoint and tears down subscriptions owned by the client.
/// </summary>
/// <param name="ct">The cancellation token that aborts disconnect cleanup.</param>
Task DisconnectAsync(CancellationToken ct = default);
/// <summary>
/// Reads the current value of an OPC UA node.
/// </summary>
/// <param name="nodeId">The node whose value should be retrieved.</param>
/// <param name="ct">The cancellation token that aborts the read request.</param>
Task<DataValue> ReadValueAsync(NodeId nodeId, CancellationToken ct = default);
/// <summary>
/// Writes an operator-supplied value to an OPC UA node after applying client-side type conversion when needed.
/// </summary>
/// <param name="nodeId">The node whose value should be updated.</param>
/// <param name="value">The raw value supplied by the CLI or UI workflow.</param>
/// <param name="ct">The cancellation token that aborts the write request.</param>
Task<StatusCode> WriteValueAsync(NodeId nodeId, object value, CancellationToken ct = default);
/// <summary>
/// Browses the children of a node so the client can build an address-space tree for operators.
/// </summary>
/// <param name="parentNodeId">The node to browse, or <see cref="ObjectIds.ObjectsFolder"/> when omitted.</param>
/// <param name="ct">The cancellation token that aborts the browse request.</param>
Task<IReadOnlyList<BrowseResult>> BrowseAsync(NodeId? parentNodeId = null, CancellationToken ct = default);
/// <summary>
/// Subscribes to live data changes for a node.
/// </summary>
/// <param name="nodeId">The node whose value changes should be monitored.</param>
/// <param name="intervalMs">The monitored-item sampling and publishing interval in milliseconds.</param>
/// <param name="ct">The cancellation token that aborts subscription creation.</param>
Task SubscribeAsync(NodeId nodeId, int intervalMs = 1000, CancellationToken ct = default);
/// <summary>
/// Removes a previously created live-data subscription for a node.
/// </summary>
/// <param name="nodeId">The node whose live-data subscription should be removed.</param>
/// <param name="ct">The cancellation token that aborts the unsubscribe request.</param>
Task UnsubscribeAsync(NodeId nodeId, CancellationToken ct = default);
/// <summary>
/// Subscribes to OPC UA alarm and condition events for a source node or the server root.
/// </summary>
/// <param name="sourceNodeId">The event source to monitor, or the server object when omitted.</param>
/// <param name="intervalMs">The publishing interval in milliseconds for the alarm subscription.</param>
/// <param name="ct">The cancellation token that aborts alarm subscription creation.</param>
Task SubscribeAlarmsAsync(NodeId? sourceNodeId = null, int intervalMs = 1000, CancellationToken ct = default);
/// <summary>
/// Removes the active alarm subscription.
/// </summary>
/// <param name="ct">The cancellation token that aborts alarm subscription cleanup.</param>
Task UnsubscribeAlarmsAsync(CancellationToken ct = default);
/// <summary>
/// Requests retained alarm conditions again so a client can repopulate its alarm list after reconnecting.
/// </summary>
/// <param name="ct">The cancellation token that aborts the condition refresh request.</param>
Task RequestConditionRefreshAsync(CancellationToken ct = default);
/// <summary>
/// Acknowledges an active condition using the event identifier returned by an alarm notification.
/// </summary>
/// <param name="conditionNodeId">The condition node associated with the alarm event being acknowledged.</param>
/// <param name="eventId">The event identifier returned by the OPC UA server for the alarm event.</param>
/// <param name="comment">The operator acknowledgment comment to write with the method call.</param>
/// <param name="ct">The cancellation token that aborts the acknowledgment request.</param>
Task<StatusCode> AcknowledgeAlarmAsync(string conditionNodeId, byte[] eventId, string comment, CancellationToken ct = default);
/// <summary>
/// Reads raw historical samples for a historized node.
/// </summary>
/// <param name="nodeId">The historized node whose samples should be read.</param>
/// <param name="startTime">The inclusive start of the requested history range.</param>
/// <param name="endTime">The inclusive end of the requested history range.</param>
/// <param name="maxValues">The maximum number of raw values to return.</param>
/// <param name="ct">The cancellation token that aborts the history read.</param>
Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
int maxValues = 1000, CancellationToken ct = default);
/// <summary>
/// Reads aggregate historical values for a historized node using an OPC UA aggregate function.
/// </summary>
/// <param name="nodeId">The historized node whose processed values should be read.</param>
/// <param name="startTime">The inclusive start of the requested processed-history range.</param>
/// <param name="endTime">The inclusive end of the requested processed-history range.</param>
/// <param name="aggregate">The aggregate function the operator selected for processed history.</param>
/// <param name="intervalMs">The processing interval, in milliseconds, for each aggregate bucket.</param>
/// <param name="ct">The cancellation token that aborts the processed history request.</param>
Task<IReadOnlyList<DataValue>> HistoryReadAggregateAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
AggregateType aggregate, double intervalMs = 3600000, CancellationToken ct = default);
/// <summary>
/// Reads redundancy status data such as redundancy mode, service level, and partner endpoint URIs.
/// </summary>
/// <param name="ct">The cancellation token that aborts redundancy inspection.</param>
Task<RedundancyInfo> GetRedundancyInfoAsync(CancellationToken ct = default);
/// <summary>
/// Raised when a subscribed node produces a new live data value.
/// </summary>
event EventHandler<DataChangedEventArgs>? DataChanged;
/// <summary>
/// Raised when an alarm or condition event is received from the server.
/// </summary>
event EventHandler<AlarmEventArgs>? AlarmEvent;
/// <summary>
/// Raised when the client changes connection state during connect, disconnect, or failover.
/// </summary>
event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
}
}
@@ -38,6 +38,9 @@ public sealed class OpcUaClientService : IOpcUaClientService
/// <summary>
/// Creates a new OpcUaClientService with the specified adapter dependencies.
/// </summary>
/// <param name="configFactory">Builds the application configuration and certificate settings for the client session.</param>
/// <param name="endpointDiscovery">Selects the best matching endpoint for the requested URL and security mode.</param>
/// <param name="sessionFactory">Creates the underlying OPC UA session used for runtime operations.</param>
internal OpcUaClientService(
IApplicationConfigurationFactory configFactory,
IEndpointDiscovery endpointDiscovery,
@@ -59,13 +62,22 @@ public sealed class OpcUaClientService : IOpcUaClientService
{
}
/// <inheritdoc />
public event EventHandler<DataChangedEventArgs>? DataChanged;
/// <inheritdoc />
public event EventHandler<AlarmEventArgs>? AlarmEvent;
/// <inheritdoc />
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
/// <inheritdoc />
public bool IsConnected => _state == ConnectionState.Connected && _session?.Connected == true;
/// <inheritdoc />
public ConnectionInfo? CurrentConnectionInfo { get; private set; }
/// <inheritdoc />
public async Task<ConnectionInfo> ConnectAsync(ConnectionSettings settings, CancellationToken ct = default)
{
ThrowIfDisposed();
@@ -99,6 +111,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
}
}
/// <inheritdoc />
public async Task DisconnectAsync(CancellationToken ct = default)
{
if (_state == ConnectionState.Disconnected)
@@ -140,6 +153,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
}
}
/// <inheritdoc />
public async Task<DataValue> ReadValueAsync(NodeId nodeId, CancellationToken ct = default)
{
ThrowIfDisposed();
@@ -147,6 +161,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
return await _session!.ReadValueAsync(nodeId, ct);
}
/// <inheritdoc />
public async Task<StatusCode> WriteValueAsync(NodeId nodeId, object value, CancellationToken ct = default)
{
ThrowIfDisposed();
@@ -164,6 +179,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
return await _session!.WriteValueAsync(nodeId, dataValue, ct);
}
/// <inheritdoc />
public async Task<IReadOnlyList<BrowseResult>> BrowseAsync(NodeId? parentNodeId = null,
CancellationToken ct = default)
{
@@ -200,6 +216,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
return results;
}
/// <inheritdoc />
public async Task SubscribeAsync(NodeId nodeId, int intervalMs = 1000, CancellationToken ct = default)
{
ThrowIfDisposed();
@@ -218,6 +235,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
Logger.Debug("Subscribed to data changes on {NodeId}", nodeId);
}
/// <inheritdoc />
public async Task UnsubscribeAsync(NodeId nodeId, CancellationToken ct = default)
{
ThrowIfDisposed();
@@ -232,6 +250,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
Logger.Debug("Unsubscribed from data changes on {NodeId}", nodeId);
}
/// <inheritdoc />
public async Task SubscribeAlarmsAsync(NodeId? sourceNodeId = null, int intervalMs = 1000,
CancellationToken ct = default)
{
@@ -252,6 +271,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
Logger.Debug("Subscribed to alarm events on {NodeId}", monitorNode);
}
/// <inheritdoc />
public async Task UnsubscribeAlarmsAsync(CancellationToken ct = default)
{
ThrowIfDisposed();
@@ -265,6 +285,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
Logger.Debug("Unsubscribed from alarm events");
}
/// <inheritdoc />
public async Task RequestConditionRefreshAsync(CancellationToken ct = default)
{
ThrowIfDisposed();
@@ -277,6 +298,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
Logger.Debug("Condition refresh requested");
}
/// <inheritdoc />
public async Task<StatusCode> AcknowledgeAlarmAsync(string conditionNodeId, byte[] eventId, string comment,
CancellationToken ct = default)
{
@@ -299,6 +321,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
return StatusCodes.Good;
}
/// <inheritdoc />
public async Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(
NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues = 1000, CancellationToken ct = default)
{
@@ -307,6 +330,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
return await _session!.HistoryReadRawAsync(nodeId, startTime, endTime, maxValues, ct);
}
/// <inheritdoc />
public async Task<IReadOnlyList<DataValue>> HistoryReadAggregateAsync(
NodeId nodeId, DateTime startTime, DateTime endTime, AggregateType aggregate,
double intervalMs = 3600000, CancellationToken ct = default)
@@ -317,6 +341,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
return await _session!.HistoryReadAggregateAsync(nodeId, startTime, endTime, aggregateNodeId, intervalMs, ct);
}
/// <inheritdoc />
public async Task<RedundancyInfo> GetRedundancyInfoAsync(CancellationToken ct = default)
{
ThrowIfDisposed();
@@ -357,6 +382,9 @@ public sealed class OpcUaClientService : IOpcUaClientService
return new RedundancyInfo(redundancyMode, serviceLevel, serverUris, applicationUri);
}
/// <summary>
/// Releases the current session and any active monitored-item subscriptions held by the client service.
/// </summary>
public void Dispose()
{
if (_disposed) return;
@@ -667,4 +695,4 @@ public sealed class OpcUaClientService : IOpcUaClientService
if (_state != ConnectionState.Connected || _session == null)
throw new InvalidOperationException("Not connected to an OPC UA server.");
}
}
}
@@ -7,14 +7,53 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Services;
/// </summary>
public sealed class UserSettings
{
/// <summary>
/// Gets or sets the last OPC UA endpoint URL entered by the user.
/// </summary>
public string EndpointUrl { get; set; } = "opc.tcp://localhost:4840";
/// <summary>
/// Gets or sets the persisted username for authenticated sessions.
/// </summary>
public string? Username { get; set; }
/// <summary>
/// Gets or sets the persisted password for authenticated sessions.
/// </summary>
public string? Password { get; set; }
/// <summary>
/// Gets or sets the transport security mode selected by the user.
/// </summary>
public SecurityMode SecurityMode { get; set; } = SecurityMode.None;
/// <summary>
/// Gets or sets the raw failover endpoint list entered in the UI.
/// </summary>
public string? FailoverUrls { get; set; }
/// <summary>
/// Gets or sets the persisted session timeout, in seconds, for new client connections.
/// </summary>
public int SessionTimeoutSeconds { get; set; } = 60;
/// <summary>
/// Gets or sets a value indicating whether untrusted server certificates should be auto-accepted.
/// </summary>
public bool AutoAcceptCertificates { get; set; } = true;
/// <summary>
/// Gets or sets the certificate store path used for client certificates and trust lists.
/// </summary>
public string? CertificateStorePath { get; set; }
/// <summary>
/// Gets or sets the node IDs that should be re-subscribed when the UI reconnects.
/// </summary>
public List<string> SubscribedNodes { get; set; } = [];
/// <summary>
/// Gets or sets the alarm source node that should be restored when the UI reconnects.
/// </summary>
public string? AlarmSourceNodeId { get; set; }
}
@@ -7,6 +7,19 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
/// </summary>
public class AlarmEventViewModel : ObservableObject
{
/// <summary>
/// Creates an alarm row model from an OPC UA condition event so the alarms tab can display and acknowledge it.
/// </summary>
/// <param name="sourceName">The source object or variable name associated with the alarm.</param>
/// <param name="conditionName">The OPC UA condition name reported by the server.</param>
/// <param name="severity">The alarm severity value presented to the operator.</param>
/// <param name="message">The alarm message text shown in the UI.</param>
/// <param name="retain">Indicates whether the server is retaining the alarm condition.</param>
/// <param name="activeState">Indicates whether the alarm is currently active.</param>
/// <param name="ackedState">Indicates whether the alarm has already been acknowledged.</param>
/// <param name="time">The event timestamp associated with the alarm state.</param>
/// <param name="eventId">The OPC UA event identifier used for acknowledgment calls.</param>
/// <param name="conditionNodeId">The condition node identifier used when acknowledging the alarm.</param>
public AlarmEventViewModel(
string sourceName,
string conditionName,
@@ -31,15 +44,54 @@ public class AlarmEventViewModel : ObservableObject
ConditionNodeId = conditionNodeId;
}
/// <summary>
/// Gets the source object or variable name associated with the alarm.
/// </summary>
public string SourceName { get; }
/// <summary>
/// Gets the OPC UA condition name reported for the alarm event.
/// </summary>
public string ConditionName { get; }
/// <summary>
/// Gets the severity value shown to the operator.
/// </summary>
public ushort Severity { get; }
/// <summary>
/// Gets the alarm message text shown in the UI.
/// </summary>
public string Message { get; }
/// <summary>
/// Gets a value indicating whether the server is retaining the condition.
/// </summary>
public bool Retain { get; }
/// <summary>
/// Gets a value indicating whether the alarm is currently active.
/// </summary>
public bool ActiveState { get; }
/// <summary>
/// Gets a value indicating whether the alarm has already been acknowledged.
/// </summary>
public bool AckedState { get; }
/// <summary>
/// Gets the event timestamp displayed for the alarm row.
/// </summary>
public DateTime Time { get; }
/// <summary>
/// Gets the OPC UA event identifier needed to acknowledge the alarm.
/// </summary>
public byte[]? EventId { get; }
/// <summary>
/// Gets the condition node identifier used for acknowledgment method calls.
/// </summary>
public string? ConditionNodeId { get; }
/// <summary>Whether this alarm can be acknowledged (active, not yet acked, has EventId).</summary>
@@ -58,6 +58,12 @@ public partial class MainWindowViewModel : ObservableObject
[ObservableProperty] private string? _username;
/// <summary>
/// Creates the main shell view model that coordinates connection state, browsing, subscriptions, alarms, history, and persisted settings.
/// </summary>
/// <param name="factory">Creates the shared OPC UA client service used by all panels.</param>
/// <param name="dispatcher">Marshals service callbacks back onto the UI thread.</param>
/// <param name="settingsService">Loads and saves persisted user connection settings.</param>
public MainWindowViewModel(IOpcUaClientServiceFactory factory, IUiDispatcher dispatcher,
ISettingsService? settingsService = null)
{
@@ -71,21 +77,49 @@ public partial class MainWindowViewModel : ObservableObject
/// <summary>All available security modes.</summary>
public IReadOnlyList<SecurityMode> SecurityModes { get; } = Enum.GetValues<SecurityMode>();
/// <summary>
/// Gets a value indicating whether the shell is currently connected to an OPC UA endpoint.
/// </summary>
public bool IsConnected => ConnectionState == ConnectionState.Connected;
/// <summary>The currently selected tree nodes (supports multi-select).</summary>
public ObservableCollection<TreeNodeViewModel> SelectedTreeNodes { get; } = [];
/// <summary>
/// Gets the browse-tree panel view model for the address-space explorer.
/// </summary>
public BrowseTreeViewModel? BrowseTree { get; private set; }
/// <summary>
/// Gets the read/write panel view model for point operations against the selected node.
/// </summary>
public ReadWriteViewModel? ReadWrite { get; private set; }
/// <summary>
/// Gets the subscriptions panel view model for live data monitoring.
/// </summary>
public SubscriptionsViewModel? Subscriptions { get; private set; }
/// <summary>
/// Gets the alarms panel view model for active-condition monitoring and acknowledgment.
/// </summary>
public AlarmsViewModel? Alarms { get; private set; }
/// <summary>
/// Gets the history panel view model for raw and aggregate history queries.
/// </summary>
public HistoryViewModel? History { get; private set; }
/// <summary>
/// Gets the subscriptions tab header, including the current active subscription count when nonzero.
/// </summary>
public string SubscriptionsTabHeader => SubscriptionCount > 0
? $"Subscriptions ({SubscriptionCount})"
: "Subscriptions";
/// <summary>
/// Gets the alarms tab header, including the current active alarm count when nonzero.
/// </summary>
public string AlarmsTabHeader => ActiveAlarmCount > 0
? $"Alarms ({ActiveAlarmCount})"
: "Alarms";
@@ -355,6 +389,9 @@ public partial class MainWindowViewModel : ObservableObject
_savedAlarmSourceNodeId = s.AlarmSourceNodeId;
}
/// <summary>
/// Persists the current connection, subscription, and alarm-monitoring settings for the next UI session.
/// </summary>
public void SaveSettings()
{
_settingsService.Save(new UserSettings
@@ -381,4 +418,4 @@ public partial class MainWindowViewModel : ObservableObject
.Where(u => !string.IsNullOrEmpty(u))
.ToArray();
}
}
}
@@ -31,6 +31,11 @@ public partial class SubscriptionsViewModel : ObservableObject
[ObservableProperty] private int _subscriptionCount;
/// <summary>
/// Creates the subscriptions panel view model used to manage live data subscriptions and ad hoc writes from the UI.
/// </summary>
/// <param name="service">The shared client service that performs subscribe, unsubscribe, read, and write operations.</param>
/// <param name="dispatcher">Marshals data-change callbacks back onto the UI thread.</param>
public SubscriptionsViewModel(IOpcUaClientService service, IUiDispatcher dispatcher)
{
_service = service;
@@ -123,6 +128,8 @@ public partial class SubscriptionsViewModel : ObservableObject
/// <summary>
/// Subscribes to a node by ID (used by context menu). Skips if already subscribed.
/// </summary>
/// <param name="nodeIdStr">The node ID to subscribe to from the browse tree or persisted settings.</param>
/// <param name="intervalMs">The monitored-item interval, in milliseconds, for the subscription.</param>
public async Task AddSubscriptionForNodeAsync(string nodeIdStr, int intervalMs = 1000)
{
if (!IsConnected || string.IsNullOrWhiteSpace(nodeIdStr)) return;
@@ -151,6 +158,9 @@ public partial class SubscriptionsViewModel : ObservableObject
/// Subscribes to a node and all its Variable descendants recursively.
/// Object nodes are browsed for children; Variable nodes are subscribed directly.
/// </summary>
/// <param name="nodeIdStr">The root node whose variables should be subscribed recursively.</param>
/// <param name="nodeClass">The node class of the starting node so variables can be subscribed immediately.</param>
/// <param name="intervalMs">The monitored-item interval, in milliseconds, used for created subscriptions.</param>
public Task AddSubscriptionRecursiveAsync(string nodeIdStr, string nodeClass, int intervalMs = 1000)
{
return AddSubscriptionRecursiveAsync(nodeIdStr, nodeClass, intervalMs, maxDepth: 10, currentDepth: 0);
@@ -193,6 +203,7 @@ public partial class SubscriptionsViewModel : ObservableObject
/// <summary>
/// Restores subscriptions from a saved list of node IDs.
/// </summary>
/// <param name="nodeIds">The node IDs persisted from a prior UI session.</param>
public async Task RestoreSubscriptionsAsync(IEnumerable<string> nodeIds)
{
foreach (var nodeId in nodeIds)
@@ -203,6 +214,8 @@ public partial class SubscriptionsViewModel : ObservableObject
/// Reads the current value of a node to determine its type, validates that the raw
/// input can be parsed to that type, writes the value, and returns (success, message).
/// </summary>
/// <param name="nodeIdStr">The node ID the operator wants to write.</param>
/// <param name="rawValue">The raw text value entered by the operator.</param>
public async Task<(bool Success, string Message)> ValidateAndWriteAsync(string nodeIdStr, string rawValue)
{
try
@@ -259,4 +272,4 @@ public partial class SubscriptionsViewModel : ObservableObject
{
_service.DataChanged -= OnDataChanged;
}
}
}
@@ -69,14 +69,22 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
public Exception? WriteException { get; set; }
public Exception? ConditionRefreshException { get; set; }
// IOpcUaClientService implementation
/// <inheritdoc />
public bool IsConnected => ConnectCalled && !DisconnectCalled;
/// <inheritdoc />
public ConnectionInfo? CurrentConnectionInfo => ConnectCalled ? ConnectionInfoResult : null;
/// <inheritdoc />
public event EventHandler<DataChangedEventArgs>? DataChanged;
/// <inheritdoc />
public event EventHandler<AlarmEventArgs>? AlarmEvent;
/// <inheritdoc />
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
/// <inheritdoc />
public Task<ConnectionInfo> ConnectAsync(ConnectionSettings settings, CancellationToken ct = default)
{
ConnectCalled = true;
@@ -85,12 +93,14 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.FromResult(ConnectionInfoResult);
}
/// <inheritdoc />
public Task DisconnectAsync(CancellationToken ct = default)
{
DisconnectCalled = true;
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<DataValue> ReadValueAsync(NodeId nodeId, CancellationToken ct = default)
{
ReadNodeIds.Add(nodeId);
@@ -98,6 +108,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.FromResult(ReadValueResult);
}
/// <inheritdoc />
public Task<StatusCode> WriteValueAsync(NodeId nodeId, object value, CancellationToken ct = default)
{
WriteValues.Add((nodeId, value));
@@ -105,36 +116,42 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.FromResult(WriteStatusCodeResult);
}
/// <inheritdoc />
public Task<IReadOnlyList<BrowseResult>> BrowseAsync(NodeId? parentNodeId = null, CancellationToken ct = default)
{
BrowseNodeIds.Add(parentNodeId);
return Task.FromResult(BrowseResults);
}
/// <inheritdoc />
public Task SubscribeAsync(NodeId nodeId, int intervalMs = 1000, CancellationToken ct = default)
{
SubscribeCalls.Add((nodeId, intervalMs));
return Task.CompletedTask;
}
/// <inheritdoc />
public Task UnsubscribeAsync(NodeId nodeId, CancellationToken ct = default)
{
UnsubscribeCalls.Add(nodeId);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task SubscribeAlarmsAsync(NodeId? sourceNodeId = null, int intervalMs = 1000, CancellationToken ct = default)
{
SubscribeAlarmsCalls.Add((sourceNodeId, intervalMs));
return Task.CompletedTask;
}
/// <inheritdoc />
public Task UnsubscribeAlarmsAsync(CancellationToken ct = default)
{
UnsubscribeAlarmsCalled = true;
return Task.CompletedTask;
}
/// <inheritdoc />
public Task RequestConditionRefreshAsync(CancellationToken ct = default)
{
RequestConditionRefreshCalled = true;
@@ -142,12 +159,14 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<StatusCode> AcknowledgeAlarmAsync(string conditionNodeId, byte[] eventId, string comment,
CancellationToken ct = default)
{
return Task.FromResult(new StatusCode(StatusCodes.Good));
}
/// <inheritdoc />
public Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(
NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues = 1000, CancellationToken ct = default)
{
@@ -155,6 +174,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.FromResult(HistoryReadResult);
}
/// <inheritdoc />
public Task<IReadOnlyList<DataValue>> HistoryReadAggregateAsync(
NodeId nodeId, DateTime startTime, DateTime endTime, AggregateType aggregate,
double intervalMs = 3600000, CancellationToken ct = default)
@@ -163,12 +183,16 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.FromResult(HistoryReadResult);
}
/// <inheritdoc />
public Task<RedundancyInfo> GetRedundancyInfoAsync(CancellationToken ct = default)
{
GetRedundancyInfoCalled = true;
return Task.FromResult(RedundancyInfoResult);
}
/// <summary>
/// Marks the fake client as disposed so CLI command tests can assert cleanup behavior.
/// </summary>
public void Dispose()
{
DisposeCalled = true;
@@ -191,4 +215,4 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
{
ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(oldState, newState, endpointUrl));
}
}
}
@@ -3,12 +3,22 @@ using ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters;
namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests.Fakes;
/// <summary>
/// Test double for <see cref="ISessionAdapter" /> used to simulate reads, writes, browsing, history, and failover callbacks.
/// </summary>
internal sealed class FakeSessionAdapter : ISessionAdapter
{
private readonly List<FakeSubscriptionAdapter> _createdSubscriptions = [];
private Action<bool>? _keepAliveCallback;
/// <summary>
/// Gets a value indicating whether the fake session has been closed through the client disconnect path.
/// </summary>
public bool Closed { get; private set; }
/// <summary>
/// Gets a value indicating whether the fake session has been disposed.
/// </summary>
public bool Disposed { get; private set; }
public int ReadCount { get; private set; }
public int WriteCount { get; private set; }
@@ -38,27 +48,47 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
public bool ThrowOnHistoryReadAggregate { get; set; }
/// <summary>
/// The next FakeSubscriptionAdapter to return from CreateSubscriptionAsync.
/// If null, a new one is created automatically.
/// Gets or sets the next fake subscription returned when the client creates a monitored-item subscription.
/// If unset, the fake builds a new subscription automatically.
/// </summary>
public FakeSubscriptionAdapter? NextSubscription { get; set; }
/// <summary>
/// Gets the fake subscriptions created by this session so tests can inspect replay and cleanup behavior.
/// </summary>
public IReadOnlyList<FakeSubscriptionAdapter> CreatedSubscriptions => _createdSubscriptions;
/// <inheritdoc />
public bool Connected { get; set; } = true;
/// <inheritdoc />
public string SessionId { get; set; } = "ns=0;i=12345";
/// <inheritdoc />
public string SessionName { get; set; } = "FakeSession";
/// <inheritdoc />
public string EndpointUrl { get; set; } = "opc.tcp://localhost:4840";
/// <inheritdoc />
public string ServerName { get; set; } = "FakeServer";
/// <inheritdoc />
public string SecurityMode { get; set; } = "None";
/// <inheritdoc />
public string SecurityPolicyUri { get; set; } = "http://opcfoundation.org/UA/SecurityPolicy#None";
/// <inheritdoc />
public NamespaceTable NamespaceUris { get; set; } = new();
/// <inheritdoc />
public void RegisterKeepAliveHandler(Action<bool> callback)
{
_keepAliveCallback = callback;
}
/// <inheritdoc />
public Task<DataValue> ReadValueAsync(NodeId nodeId, CancellationToken ct)
{
ReadCount++;
@@ -71,6 +101,7 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
return Task.FromResult(ReadResponse ?? new DataValue(new Variant(0), StatusCodes.Good));
}
/// <inheritdoc />
public Task<StatusCode> WriteValueAsync(NodeId nodeId, DataValue value, CancellationToken ct)
{
WriteCount++;
@@ -79,6 +110,7 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
return Task.FromResult(WriteResponse);
}
/// <inheritdoc />
public Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseAsync(
NodeId nodeId, uint nodeClassMask, CancellationToken ct)
{
@@ -88,6 +120,7 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
return Task.FromResult((BrowseContinuationPoint, BrowseResponse));
}
/// <inheritdoc />
public Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseNextAsync(
byte[] continuationPoint, CancellationToken ct)
{
@@ -95,12 +128,14 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
return Task.FromResult((BrowseNextContinuationPoint, BrowseNextResponse));
}
/// <inheritdoc />
public Task<bool> HasChildrenAsync(NodeId nodeId, CancellationToken ct)
{
HasChildrenCount++;
return Task.FromResult(HasChildrenResponse);
}
/// <inheritdoc />
public Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(
NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues, CancellationToken ct)
{
@@ -110,6 +145,7 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
return Task.FromResult<IReadOnlyList<DataValue>>(HistoryReadRawResponse);
}
/// <inheritdoc />
public Task<IReadOnlyList<DataValue>> HistoryReadAggregateAsync(
NodeId nodeId, DateTime startTime, DateTime endTime, NodeId aggregateId, double intervalMs,
CancellationToken ct)
@@ -120,6 +156,7 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
return Task.FromResult<IReadOnlyList<DataValue>>(HistoryReadAggregateResponse);
}
/// <inheritdoc />
public Task<ISubscriptionAdapter> CreateSubscriptionAsync(int publishingIntervalMs, CancellationToken ct)
{
var sub = NextSubscription ?? new FakeSubscriptionAdapter();
@@ -128,12 +165,14 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
return Task.FromResult<ISubscriptionAdapter>(sub);
}
/// <inheritdoc />
public Task<IList<object>?> CallMethodAsync(NodeId objectId, NodeId methodId, object[] inputArguments,
CancellationToken ct = default)
{
return Task.FromResult<IList<object>?>(null);
}
/// <inheritdoc />
public Task CloseAsync(CancellationToken ct)
{
Closed = true;
@@ -141,6 +180,9 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
return Task.CompletedTask;
}
/// <summary>
/// Marks the fake session as disposed so tests can verify cleanup after disconnect or failover.
/// </summary>
public void Dispose()
{
Disposed = true;
@@ -154,4 +196,4 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
{
_keepAliveCallback?.Invoke(isGood);
}
}
}
@@ -3,6 +3,9 @@ using ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters;
namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests.Fakes;
/// <summary>
/// Test double for <see cref="ISubscriptionAdapter" /> used to drive monitored-item behavior in shared-client tests.
/// </summary>
internal sealed class FakeSubscriptionAdapter : ISubscriptionAdapter
{
private readonly
@@ -10,8 +13,19 @@ internal sealed class FakeSubscriptionAdapter : ISubscriptionAdapter
)> _items = new();
private uint _nextHandle = 100;
/// <summary>
/// Gets a value indicating whether the fake subscription has been deleted.
/// </summary>
public bool Deleted { get; private set; }
/// <summary>
/// Gets a value indicating whether a condition refresh was requested by the client under test.
/// </summary>
public bool ConditionRefreshCalled { get; private set; }
/// <summary>
/// Gets or sets a value indicating whether condition refresh should throw to simulate unsupported servers.
/// </summary>
public bool ThrowOnConditionRefresh { get; set; }
public int AddDataChangeCount { get; private set; }
public int AddEventCount { get; private set; }
@@ -22,8 +36,10 @@ internal sealed class FakeSubscriptionAdapter : ISubscriptionAdapter
/// </summary>
public IReadOnlyCollection<uint> ActiveHandles => _items.Keys.ToList();
/// <inheritdoc />
public uint SubscriptionId { get; set; } = 42;
/// <inheritdoc />
public Task<uint> AddDataChangeMonitoredItemAsync(NodeId nodeId, int samplingIntervalMs,
Action<string, DataValue> onDataChange, CancellationToken ct)
{
@@ -33,6 +49,7 @@ internal sealed class FakeSubscriptionAdapter : ISubscriptionAdapter
return Task.FromResult(handle);
}
/// <inheritdoc />
public Task RemoveMonitoredItemAsync(uint clientHandle, CancellationToken ct)
{
RemoveCount++;
@@ -40,6 +57,7 @@ internal sealed class FakeSubscriptionAdapter : ISubscriptionAdapter
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<uint> AddEventMonitoredItemAsync(NodeId nodeId, int samplingIntervalMs, EventFilter filter,
Action<EventFieldList> onEvent, CancellationToken ct)
{
@@ -49,6 +67,7 @@ internal sealed class FakeSubscriptionAdapter : ISubscriptionAdapter
return Task.FromResult(handle);
}
/// <inheritdoc />
public Task ConditionRefreshAsync(CancellationToken ct)
{
ConditionRefreshCalled = true;
@@ -57,6 +76,7 @@ internal sealed class FakeSubscriptionAdapter : ISubscriptionAdapter
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteAsync(CancellationToken ct)
{
Deleted = true;
@@ -64,6 +84,9 @@ internal sealed class FakeSubscriptionAdapter : ISubscriptionAdapter
return Task.CompletedTask;
}
/// <summary>
/// Clears tracked monitored items when the fake subscription is disposed by the client under test.
/// </summary>
public void Dispose()
{
_items.Clear();
@@ -85,4 +108,4 @@ internal sealed class FakeSubscriptionAdapter : ISubscriptionAdapter
{
if (_items.TryGetValue(handle, out var item) && item.EventCallback != null) item.EventCallback(eventFields);
}
}
}
@@ -6,6 +6,9 @@ using ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests.Fakes;
namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests;
/// <summary>
/// Verifies the shared OPC UA client service behaviors for connection management, browsing, subscriptions, history, alarms, and redundancy.
/// </summary>
public class OpcUaClientServiceTests : IDisposable
{
private readonly FakeApplicationConfigurationFactory _configFactory = new();
@@ -18,6 +21,9 @@ public class OpcUaClientServiceTests : IDisposable
_service = new OpcUaClientService(_configFactory, _endpointDiscovery, _sessionFactory);
}
/// <summary>
/// Releases the shared client service after each test so session and subscription state do not leak between scenarios.
/// </summary>
public void Dispose()
{
_service.Dispose();
@@ -34,6 +40,9 @@ public class OpcUaClientServiceTests : IDisposable
// --- Connection tests ---
/// <summary>
/// Verifies that a valid connection request returns populated connection metadata and marks the client as connected.
/// </summary>
[Fact]
public async Task ConnectAsync_ValidSettings_ReturnsConnectionInfo()
{
@@ -45,6 +54,9 @@ public class OpcUaClientServiceTests : IDisposable
_service.CurrentConnectionInfo.ShouldBe(info);
}
/// <summary>
/// Verifies that invalid connection settings fail validation before any OPC UA session is created.
/// </summary>
[Fact]
public async Task ConnectAsync_InvalidSettings_ThrowsBeforeCreatingSession()
{
@@ -55,6 +67,9 @@ public class OpcUaClientServiceTests : IDisposable
_service.IsConnected.ShouldBeFalse();
}
/// <summary>
/// Verifies that server and security details from the session are copied into the exposed connection info.
/// </summary>
[Fact]
public async Task ConnectAsync_PopulatesConnectionInfo()
{
@@ -77,6 +92,9 @@ public class OpcUaClientServiceTests : IDisposable
info.SessionName.ShouldBe("TestSession");
}
/// <summary>
/// Verifies that connection-state transitions are raised for the connecting and connected phases.
/// </summary>
[Fact]
public async Task ConnectAsync_RaisesConnectionStateChangedEvents()
{
@@ -92,6 +110,9 @@ public class OpcUaClientServiceTests : IDisposable
events[1].NewState.ShouldBe(ConnectionState.Connected);
}
/// <summary>
/// Verifies that a failed session creation leaves the client in the disconnected state.
/// </summary>
[Fact]
public async Task ConnectAsync_SessionFactoryFails_TransitionsToDisconnected()
{
@@ -105,6 +126,9 @@ public class OpcUaClientServiceTests : IDisposable
events.Last().NewState.ShouldBe(ConnectionState.Disconnected);
}
/// <summary>
/// Verifies that username and password settings are passed through to the session-creation pipeline.
/// </summary>
[Fact]
public async Task ConnectAsync_WithUsername_PassesThroughToFactory()
{
@@ -120,6 +144,9 @@ public class OpcUaClientServiceTests : IDisposable
// --- Disconnect tests ---
/// <summary>
/// Verifies that disconnect closes the active session and clears exposed connection state.
/// </summary>
[Fact]
public async Task DisconnectAsync_WhenConnected_ClosesSession()
{
@@ -133,6 +160,9 @@ public class OpcUaClientServiceTests : IDisposable
_service.CurrentConnectionInfo.ShouldBeNull();
}
/// <summary>
/// Verifies that disconnect is safe to call when no server session is active.
/// </summary>
[Fact]
public async Task DisconnectAsync_WhenNotConnected_IsIdempotent()
{
@@ -140,6 +170,9 @@ public class OpcUaClientServiceTests : IDisposable
_service.IsConnected.ShouldBeFalse();
}
/// <summary>
/// Verifies that repeated disconnect calls do not throw after cleanup has already run.
/// </summary>
[Fact]
public async Task DisconnectAsync_CalledTwice_IsIdempotent()
{
@@ -150,6 +183,9 @@ public class OpcUaClientServiceTests : IDisposable
// --- Read tests ---
/// <summary>
/// Verifies that a connected client can read the current value of a node through the session adapter.
/// </summary>
[Fact]
public async Task ReadValueAsync_WhenConnected_ReturnsValue()
{
@@ -166,6 +202,9 @@ public class OpcUaClientServiceTests : IDisposable
session.ReadCount.ShouldBe(1);
}
/// <summary>
/// Verifies that reads are rejected when the client is not connected to a server.
/// </summary>
[Fact]
public async Task ReadValueAsync_WhenDisconnected_Throws()
{
@@ -173,6 +212,9 @@ public class OpcUaClientServiceTests : IDisposable
_service.ReadValueAsync(new NodeId("ns=2;s=MyNode")));
}
/// <summary>
/// Verifies that session-level read failures are surfaced to callers instead of being swallowed.
/// </summary>
[Fact]
public async Task ReadValueAsync_SessionThrows_PropagatesException()
{
@@ -186,6 +228,9 @@ public class OpcUaClientServiceTests : IDisposable
// --- Write tests ---
/// <summary>
/// Verifies that writes succeed through the session adapter when the client is connected.
/// </summary>
[Fact]
public async Task WriteValueAsync_WhenConnected_WritesValue()
{
@@ -203,6 +248,9 @@ public class OpcUaClientServiceTests : IDisposable
session.WriteCount.ShouldBe(1);
}
/// <summary>
/// Verifies that string inputs are coerced to the node's current data type before writing.
/// </summary>
[Fact]
public async Task WriteValueAsync_StringValue_CoercesToTargetType()
{
@@ -219,6 +267,9 @@ public class OpcUaClientServiceTests : IDisposable
session.ReadCount.ShouldBe(1); // Read for type inference
}
/// <summary>
/// Verifies that non-string values are written directly without an extra type-inference read.
/// </summary>
[Fact]
public async Task WriteValueAsync_NonStringValue_WritesDirectly()
{
@@ -232,6 +283,9 @@ public class OpcUaClientServiceTests : IDisposable
session.ReadCount.ShouldBe(0); // No read for non-string values
}
/// <summary>
/// Verifies that writes are rejected when the client is disconnected.
/// </summary>
[Fact]
public async Task WriteValueAsync_WhenDisconnected_Throws()
{
@@ -241,6 +295,9 @@ public class OpcUaClientServiceTests : IDisposable
// --- Browse tests ---
/// <summary>
/// Verifies that browse results are mapped into the client browse model used by CLI and UI consumers.
/// </summary>
[Fact]
public async Task BrowseAsync_WhenConnected_ReturnsMappedResults()
{
@@ -267,6 +324,9 @@ public class OpcUaClientServiceTests : IDisposable
results[0].HasChildren.ShouldBeFalse(); // Variable nodes don't check HasChildren
}
/// <summary>
/// Verifies that a null browse root defaults to the OPC UA Objects folder.
/// </summary>
[Fact]
public async Task BrowseAsync_NullParent_UsesObjectsFolder()
{
@@ -282,6 +342,9 @@ public class OpcUaClientServiceTests : IDisposable
session.BrowseCount.ShouldBe(1);
}
/// <summary>
/// Verifies that object nodes trigger child-detection checks so the client can mark expandable branches.
/// </summary>
[Fact]
public async Task BrowseAsync_ObjectNode_ChecksHasChildren()
{
@@ -307,6 +370,9 @@ public class OpcUaClientServiceTests : IDisposable
session.HasChildrenCount.ShouldBe(1);
}
/// <summary>
/// Verifies that browse continuation points are followed so multi-page address-space branches are fully returned.
/// </summary>
[Fact]
public async Task BrowseAsync_WithContinuationPoint_FollowsIt()
{
@@ -342,6 +408,9 @@ public class OpcUaClientServiceTests : IDisposable
session.BrowseNextCount.ShouldBe(1);
}
/// <summary>
/// Verifies that browse requests are rejected when the client is disconnected.
/// </summary>
[Fact]
public async Task BrowseAsync_WhenDisconnected_Throws()
{
@@ -350,6 +419,9 @@ public class OpcUaClientServiceTests : IDisposable
// --- Subscribe tests ---
/// <summary>
/// Verifies that subscribing to a node creates a monitored item on a data-change subscription.
/// </summary>
[Fact]
public async Task SubscribeAsync_CreatesSubscription()
{
@@ -363,6 +435,9 @@ public class OpcUaClientServiceTests : IDisposable
session.CreatedSubscriptions[0].AddDataChangeCount.ShouldBe(1);
}
/// <summary>
/// Verifies that duplicate subscribe requests for the same node do not create duplicate monitored items.
/// </summary>
[Fact]
public async Task SubscribeAsync_DuplicateNode_IsIdempotent()
{
@@ -376,6 +451,9 @@ public class OpcUaClientServiceTests : IDisposable
session.CreatedSubscriptions[0].AddDataChangeCount.ShouldBe(1);
}
/// <summary>
/// Verifies that data-change notifications from the subscription are raised through the shared client event.
/// </summary>
[Fact]
public async Task SubscribeAsync_RaisesDataChangedEvent()
{
@@ -398,6 +476,9 @@ public class OpcUaClientServiceTests : IDisposable
received.Value.Value.ShouldBe(99);
}
/// <summary>
/// Verifies that unsubscribing removes the corresponding monitored item from the active subscription.
/// </summary>
[Fact]
public async Task UnsubscribeAsync_RemovesMonitoredItem()
{
@@ -412,6 +493,9 @@ public class OpcUaClientServiceTests : IDisposable
fakeSub.RemoveCount.ShouldBe(1);
}
/// <summary>
/// Verifies that unsubscribing an unknown node is treated as a safe no-op.
/// </summary>
[Fact]
public async Task UnsubscribeAsync_WhenNotSubscribed_DoesNotThrow()
{
@@ -423,6 +507,9 @@ public class OpcUaClientServiceTests : IDisposable
// Should not throw
}
/// <summary>
/// Verifies that data subscriptions cannot be created while the client is disconnected.
/// </summary>
[Fact]
public async Task SubscribeAsync_WhenDisconnected_Throws()
{
@@ -432,6 +519,9 @@ public class OpcUaClientServiceTests : IDisposable
// --- Alarm subscription tests ---
/// <summary>
/// Verifies that alarm subscription requests create an event monitored item on the session.
/// </summary>
[Fact]
public async Task SubscribeAlarmsAsync_CreatesEventSubscription()
{
@@ -445,6 +535,9 @@ public class OpcUaClientServiceTests : IDisposable
session.CreatedSubscriptions[0].AddEventCount.ShouldBe(1);
}
/// <summary>
/// Verifies that duplicate alarm-subscription requests do not create duplicate event subscriptions.
/// </summary>
[Fact]
public async Task SubscribeAlarmsAsync_Duplicate_IsIdempotent()
{
@@ -458,6 +551,9 @@ public class OpcUaClientServiceTests : IDisposable
session.CreatedSubscriptions.Count.ShouldBe(1);
}
/// <summary>
/// Verifies that OPC UA event notifications are mapped into the shared client alarm event model.
/// </summary>
[Fact]
public async Task SubscribeAlarmsAsync_RaisesAlarmEvent()
{
@@ -503,6 +599,9 @@ public class OpcUaClientServiceTests : IDisposable
received.AckedState.ShouldBeFalse();
}
/// <summary>
/// Verifies that removing alarm monitoring deletes the underlying event subscription.
/// </summary>
[Fact]
public async Task UnsubscribeAlarmsAsync_DeletesSubscription()
{
@@ -517,6 +616,9 @@ public class OpcUaClientServiceTests : IDisposable
fakeSub.Deleted.ShouldBeTrue();
}
/// <summary>
/// Verifies that removing alarms is safe even when no alarm subscription exists.
/// </summary>
[Fact]
public async Task UnsubscribeAlarmsAsync_WhenNoSubscription_DoesNotThrow()
{
@@ -527,6 +629,9 @@ public class OpcUaClientServiceTests : IDisposable
await _service.UnsubscribeAlarmsAsync(); // Should not throw
}
/// <summary>
/// Verifies that condition refresh requests are forwarded to the active alarm subscription.
/// </summary>
[Fact]
public async Task RequestConditionRefreshAsync_CallsAdapter()
{
@@ -541,6 +646,9 @@ public class OpcUaClientServiceTests : IDisposable
fakeSub.ConditionRefreshCalled.ShouldBeTrue();
}
/// <summary>
/// Verifies that condition refresh fails fast when no alarm subscription is active.
/// </summary>
[Fact]
public async Task RequestConditionRefreshAsync_NoAlarmSubscription_Throws()
{
@@ -552,6 +660,9 @@ public class OpcUaClientServiceTests : IDisposable
_service.RequestConditionRefreshAsync());
}
/// <summary>
/// Verifies that alarm subscriptions cannot be created while disconnected.
/// </summary>
[Fact]
public async Task SubscribeAlarmsAsync_WhenDisconnected_Throws()
{
@@ -561,6 +672,9 @@ public class OpcUaClientServiceTests : IDisposable
// --- History read tests ---
/// <summary>
/// Verifies that raw history reads return the session-provided values.
/// </summary>
[Fact]
public async Task HistoryReadRawAsync_ReturnsValues()
{
@@ -580,6 +694,9 @@ public class OpcUaClientServiceTests : IDisposable
session.HistoryReadRawCount.ShouldBe(1);
}
/// <summary>
/// Verifies that raw history reads are rejected while disconnected.
/// </summary>
[Fact]
public async Task HistoryReadRawAsync_WhenDisconnected_Throws()
{
@@ -587,6 +704,9 @@ public class OpcUaClientServiceTests : IDisposable
_service.HistoryReadRawAsync(new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow));
}
/// <summary>
/// Verifies that raw-history failures from the session are propagated to callers.
/// </summary>
[Fact]
public async Task HistoryReadRawAsync_SessionThrows_PropagatesException()
{
@@ -598,6 +718,9 @@ public class OpcUaClientServiceTests : IDisposable
_service.HistoryReadRawAsync(new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow));
}
/// <summary>
/// Verifies that aggregate history reads return the processed values from the session adapter.
/// </summary>
[Fact]
public async Task HistoryReadAggregateAsync_ReturnsValues()
{
@@ -617,6 +740,9 @@ public class OpcUaClientServiceTests : IDisposable
session.HistoryReadAggregateCount.ShouldBe(1);
}
/// <summary>
/// Verifies that aggregate history reads are rejected while disconnected.
/// </summary>
[Fact]
public async Task HistoryReadAggregateAsync_WhenDisconnected_Throws()
{
@@ -626,6 +752,9 @@ public class OpcUaClientServiceTests : IDisposable
AggregateType.Average));
}
/// <summary>
/// Verifies that aggregate-history failures from the session are propagated to callers.
/// </summary>
[Fact]
public async Task HistoryReadAggregateAsync_SessionThrows_PropagatesException()
{
@@ -641,6 +770,9 @@ public class OpcUaClientServiceTests : IDisposable
// --- Redundancy tests ---
/// <summary>
/// Verifies that redundancy mode, service level, and server URIs are read from the standard OPC UA redundancy nodes.
/// </summary>
[Fact]
public async Task GetRedundancyInfoAsync_ReturnsInfo()
{
@@ -670,6 +802,9 @@ public class OpcUaClientServiceTests : IDisposable
info.ApplicationUri.ShouldBe("urn:server1");
}
/// <summary>
/// Verifies that missing optional redundancy arrays do not prevent a redundancy snapshot from being returned.
/// </summary>
[Fact]
public async Task GetRedundancyInfoAsync_MissingOptionalArrays_ReturnsGracefully()
{
@@ -697,6 +832,9 @@ public class OpcUaClientServiceTests : IDisposable
info.ApplicationUri.ShouldBeEmpty();
}
/// <summary>
/// Verifies that redundancy inspection is rejected while disconnected.
/// </summary>
[Fact]
public async Task GetRedundancyInfoAsync_WhenDisconnected_Throws()
{
@@ -706,6 +844,9 @@ public class OpcUaClientServiceTests : IDisposable
// --- Failover tests ---
/// <summary>
/// Verifies that a keep-alive failure moves the client to a configured failover endpoint.
/// </summary>
[Fact]
public async Task KeepAliveFailure_TriggersFailover()
{
@@ -734,6 +875,9 @@ public class OpcUaClientServiceTests : IDisposable
e.EndpointUrl == "opc.tcp://backup:4840");
}
/// <summary>
/// Verifies that connection metadata is refreshed to reflect the newly active failover endpoint.
/// </summary>
[Fact]
public async Task KeepAliveFailure_UpdatesConnectionInfo()
{
@@ -757,6 +901,9 @@ public class OpcUaClientServiceTests : IDisposable
_service.CurrentConnectionInfo.ServerName.ShouldBe("BackupServer");
}
/// <summary>
/// Verifies that the client falls back to disconnected when every failover endpoint is unreachable.
/// </summary>
[Fact]
public async Task KeepAliveFailure_AllEndpointsFail_TransitionsToDisconnected()
{
@@ -775,6 +922,9 @@ public class OpcUaClientServiceTests : IDisposable
// --- Dispose tests ---
/// <summary>
/// Verifies that dispose releases the underlying session and clears exposed connection state.
/// </summary>
[Fact]
public async Task Dispose_CleansUpResources()
{
@@ -788,12 +938,18 @@ public class OpcUaClientServiceTests : IDisposable
_service.CurrentConnectionInfo.ShouldBeNull();
}
/// <summary>
/// Verifies that dispose is safe to call even when no connection was established.
/// </summary>
[Fact]
public void Dispose_WhenNotConnected_DoesNotThrow()
{
_service.Dispose(); // Should not throw
}
/// <summary>
/// Verifies that public operations reject use after the shared client has been disposed.
/// </summary>
[Fact]
public async Task OperationsAfterDispose_Throw()
{
@@ -807,6 +963,9 @@ public class OpcUaClientServiceTests : IDisposable
// --- Factory tests ---
/// <summary>
/// Verifies that the factory creates a usable shared OPC UA client service instance.
/// </summary>
[Fact]
public void OpcUaClientServiceFactory_CreatesService()
{
@@ -816,4 +975,4 @@ public class OpcUaClientServiceTests : IDisposable
service.ShouldBeAssignableTo<IOpcUaClientService>();
service.Dispose();
}
}
}
@@ -6,27 +6,81 @@ using BrowseResult = ZB.MOM.WW.LmxOpcUa.Client.Shared.Models.BrowseResult;
namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Tests.Fakes;
/// <summary>
/// Fake IOpcUaClientService for unit testing.
/// Test double for the shared OPC UA client service used by UI view-model tests.
/// It lets tests script connection, browse, history, redundancy, and alarm behavior without a live server.
/// </summary>
public sealed class FakeOpcUaClientService : IOpcUaClientService
{
// Configurable responses
/// <summary>
/// Gets or sets the connection metadata returned when a UI test performs a successful connect.
/// </summary>
public ConnectionInfo? ConnectResult { get; set; }
/// <summary>
/// Gets or sets the exception thrown to simulate a failed connect workflow in the UI.
/// </summary>
public Exception? ConnectException { get; set; }
/// <summary>
/// Gets or sets the default browse results returned when no parent-specific branch is configured.
/// </summary>
public IReadOnlyList<BrowseResult> BrowseResults { get; set; } = [];
/// <summary>
/// Gets or sets browse results keyed by parent node so tree-navigation tests can model multiple address-space branches.
/// </summary>
public Dictionary<string, IReadOnlyList<BrowseResult>> BrowseResultsByParent { get; set; } = new();
/// <summary>
/// Gets or sets the exception thrown to simulate browse failures in the UI tree.
/// </summary>
public Exception? BrowseException { get; set; }
/// <summary>
/// Gets or sets the current value returned by point-read tests.
/// </summary>
public DataValue ReadResult { get; set; } =
new(new Variant(42), StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow);
/// <summary>
/// Gets or sets the exception thrown to simulate point-read failures in the UI.
/// </summary>
public Exception? ReadException { get; set; }
/// <summary>
/// Gets or sets the status code returned by write operations in UI tests.
/// </summary>
public StatusCode WriteResult { get; set; } = StatusCodes.Good;
/// <summary>
/// Gets or sets the exception thrown to simulate failed write workflows in the UI.
/// </summary>
public Exception? WriteException { get; set; }
/// <summary>
/// Gets or sets the redundancy snapshot returned when the UI inspects server redundancy state.
/// </summary>
public RedundancyInfo? RedundancyResult { get; set; }
/// <summary>
/// Gets or sets the exception thrown to simulate redundancy lookup failures.
/// </summary>
public Exception? RedundancyException { get; set; }
/// <summary>
/// Gets or sets the raw historical values returned to history-view tests.
/// </summary>
public IReadOnlyList<DataValue> HistoryRawResult { get; set; } = [];
/// <summary>
/// Gets or sets the aggregate historical values returned to history-view tests.
/// </summary>
public IReadOnlyList<DataValue> HistoryAggregateResult { get; set; } = [];
/// <summary>
/// Gets or sets the exception thrown to simulate historical-data failures in the UI.
/// </summary>
public Exception? HistoryException { get; set; }
// Call tracking
@@ -54,13 +108,22 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
public NodeId? LastUnsubscribeNodeId { get; private set; }
public AggregateType? LastAggregateType { get; private set; }
/// <inheritdoc />
public bool IsConnected { get; set; }
/// <inheritdoc />
public ConnectionInfo? CurrentConnectionInfo { get; set; }
/// <inheritdoc />
public event EventHandler<DataChangedEventArgs>? DataChanged;
/// <inheritdoc />
public event EventHandler<AlarmEventArgs>? AlarmEvent;
/// <inheritdoc />
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
/// <inheritdoc />
public Task<ConnectionInfo> ConnectAsync(ConnectionSettings settings, CancellationToken ct = default)
{
ConnectCallCount++;
@@ -71,6 +134,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.FromResult(ConnectResult!);
}
/// <inheritdoc />
public Task DisconnectAsync(CancellationToken ct = default)
{
DisconnectCallCount++;
@@ -79,6 +143,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<DataValue> ReadValueAsync(NodeId nodeId, CancellationToken ct = default)
{
ReadCallCount++;
@@ -87,6 +152,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.FromResult(ReadResult);
}
/// <inheritdoc />
public Task<StatusCode> WriteValueAsync(NodeId nodeId, object value, CancellationToken ct = default)
{
WriteCallCount++;
@@ -96,6 +162,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.FromResult(WriteResult);
}
/// <inheritdoc />
public Task<IReadOnlyList<BrowseResult>> BrowseAsync(NodeId? parentNodeId = null, CancellationToken ct = default)
{
BrowseCallCount++;
@@ -108,6 +175,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.FromResult(BrowseResults);
}
/// <inheritdoc />
public Task SubscribeAsync(NodeId nodeId, int intervalMs = 1000, CancellationToken ct = default)
{
SubscribeCallCount++;
@@ -116,6 +184,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.CompletedTask;
}
/// <inheritdoc />
public Task UnsubscribeAsync(NodeId nodeId, CancellationToken ct = default)
{
UnsubscribeCallCount++;
@@ -123,18 +192,21 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.CompletedTask;
}
/// <inheritdoc />
public Task SubscribeAlarmsAsync(NodeId? sourceNodeId = null, int intervalMs = 1000, CancellationToken ct = default)
{
SubscribeAlarmsCallCount++;
return Task.CompletedTask;
}
/// <inheritdoc />
public Task UnsubscribeAlarmsAsync(CancellationToken ct = default)
{
UnsubscribeAlarmsCallCount++;
return Task.CompletedTask;
}
/// <inheritdoc />
public Task RequestConditionRefreshAsync(CancellationToken ct = default)
{
RequestConditionRefreshCallCount++;
@@ -145,6 +217,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
public Exception? AcknowledgeException { get; set; }
public int AcknowledgeCallCount { get; private set; }
/// <inheritdoc />
public Task<StatusCode> AcknowledgeAlarmAsync(string conditionNodeId, byte[] eventId, string comment,
CancellationToken ct = default)
{
@@ -153,6 +226,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.FromResult(AcknowledgeResult);
}
/// <inheritdoc />
public Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
int maxValues = 1000, CancellationToken ct = default)
{
@@ -161,6 +235,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.FromResult(HistoryRawResult);
}
/// <inheritdoc />
public Task<IReadOnlyList<DataValue>> HistoryReadAggregateAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
AggregateType aggregate, double intervalMs = 3600000, CancellationToken ct = default)
{
@@ -170,6 +245,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.FromResult(HistoryAggregateResult);
}
/// <inheritdoc />
public Task<RedundancyInfo> GetRedundancyInfoAsync(CancellationToken ct = default)
{
GetRedundancyInfoCallCount++;
@@ -177,24 +253,35 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.FromResult(RedundancyResult!);
}
/// <summary>
/// Releases fake service resources at the end of a UI test run.
/// </summary>
public void Dispose()
{
// No-op for testing
}
// Methods to raise events from tests
/// <summary>
/// Raises a simulated data-change notification so UI tests can validate live update handling.
/// </summary>
public void RaiseDataChanged(DataChangedEventArgs args)
{
DataChanged?.Invoke(this, args);
}
/// <summary>
/// Raises a simulated alarm event so UI tests can validate alarm-list behavior.
/// </summary>
public void RaiseAlarmEvent(AlarmEventArgs args)
{
AlarmEvent?.Invoke(this, args);
}
/// <summary>
/// Raises a simulated connection-state transition so UI tests can validate status presentation and failover behavior.
/// </summary>
public void RaiseConnectionStateChanged(ConnectionStateChangedEventArgs args)
{
ConnectionStateChanged?.Invoke(this, args);
}
}
}
@@ -9,6 +9,9 @@ using ConnectionState = ZB.MOM.WW.LmxOpcUa.Client.Shared.Models.ConnectionState;
namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Tests;
/// <summary>
/// Verifies the main UI shell behavior for connection state, settings persistence, browsing, subscriptions, and history navigation.
/// </summary>
public class MainWindowViewModelTests
{
private readonly FakeOpcUaClientService _service;
@@ -39,6 +42,9 @@ public class MainWindowViewModelTests
_vm = new MainWindowViewModel(factory, dispatcher, _settingsService);
}
/// <summary>
/// Verifies that the shell starts disconnected with the default endpoint and status text.
/// </summary>
[Fact]
public void DefaultState_IsDisconnected()
{
@@ -48,18 +54,27 @@ public class MainWindowViewModelTests
_vm.StatusMessage.ShouldBe("Disconnected");
}
/// <summary>
/// Verifies that the connect command is available before a session is established.
/// </summary>
[Fact]
public void ConnectCommand_CanExecute_WhenDisconnected()
{
_vm.ConnectCommand.CanExecute(null).ShouldBeTrue();
}
/// <summary>
/// Verifies that disconnect is disabled until a server session is active.
/// </summary>
[Fact]
public void DisconnectCommand_CannotExecute_WhenDisconnected()
{
_vm.DisconnectCommand.CanExecute(null).ShouldBeFalse();
}
/// <summary>
/// Verifies that a successful connect command updates the shell into the connected state.
/// </summary>
[Fact]
public async Task ConnectCommand_TransitionsToConnected()
{
@@ -70,6 +85,9 @@ public class MainWindowViewModelTests
_service.ConnectCallCount.ShouldBe(1);
}
/// <summary>
/// Verifies that the initial browse tree is loaded after a successful connect.
/// </summary>
[Fact]
public async Task ConnectCommand_LoadsRootNodes()
{
@@ -79,6 +97,9 @@ public class MainWindowViewModelTests
_vm.BrowseTree.RootNodes[0].DisplayName.ShouldBe("Root");
}
/// <summary>
/// Verifies that redundancy details are fetched and exposed after connecting.
/// </summary>
[Fact]
public async Task ConnectCommand_FetchesRedundancyInfo()
{
@@ -89,6 +110,9 @@ public class MainWindowViewModelTests
_vm.RedundancyInfo.ServiceLevel.ShouldBe((byte)200);
}
/// <summary>
/// Verifies that the session label shows the connected server and session identity.
/// </summary>
[Fact]
public async Task ConnectCommand_SetsSessionLabel()
{
@@ -98,6 +122,9 @@ public class MainWindowViewModelTests
_vm.SessionLabel.ShouldContain("TestSession");
}
/// <summary>
/// Verifies that disconnect returns the shell to the disconnected state.
/// </summary>
[Fact]
public async Task DisconnectCommand_TransitionsToDisconnected()
{
@@ -109,6 +136,9 @@ public class MainWindowViewModelTests
_service.DisconnectCallCount.ShouldBe(1);
}
/// <summary>
/// Verifies that disconnect clears session-specific UI state such as browse data and redundancy details.
/// </summary>
[Fact]
public async Task Disconnect_ClearsStateAndChildren()
{
@@ -121,6 +151,9 @@ public class MainWindowViewModelTests
_vm.SubscriptionCount.ShouldBe(0);
}
/// <summary>
/// Verifies that connection-state events from the client update the shell status text and state.
/// </summary>
[Fact]
public async Task ConnectionStateChangedEvent_UpdatesState()
{
@@ -134,6 +167,9 @@ public class MainWindowViewModelTests
_vm.StatusMessage.ShouldBe("Reconnecting...");
}
/// <summary>
/// Verifies that selecting a tree node updates the dependent read/write and history panels.
/// </summary>
[Fact]
public async Task SelectedTreeNode_PropagatesToChildViewModels()
{
@@ -146,6 +182,9 @@ public class MainWindowViewModelTests
_vm.History.SelectedNodeId.ShouldBe(node.NodeId);
}
/// <summary>
/// Verifies that a successful connect propagates connected state into the child tabs.
/// </summary>
[Fact]
public async Task ConnectCommand_PropagatesIsConnectedToChildViewModels()
{
@@ -157,6 +196,9 @@ public class MainWindowViewModelTests
_vm.History.IsConnected.ShouldBeTrue();
}
/// <summary>
/// Verifies that disconnect propagates disconnected state into the child tabs.
/// </summary>
[Fact]
public async Task DisconnectCommand_PropagatesIsConnectedFalseToChildViewModels()
{
@@ -169,6 +211,9 @@ public class MainWindowViewModelTests
_vm.History.IsConnected.ShouldBeFalse();
}
/// <summary>
/// Verifies that failed connection attempts restore the disconnected shell state and surface the error text.
/// </summary>
[Fact]
public async Task ConnectFailure_RevertsToDisconnected()
{
@@ -180,6 +225,9 @@ public class MainWindowViewModelTests
_vm.StatusMessage.ShouldContain("Connection refused");
}
/// <summary>
/// Verifies that connection-state transitions raise property-changed notifications for UI binding updates.
/// </summary>
[Fact]
public async Task PropertyChanged_FiredForConnectionState()
{
@@ -195,6 +243,9 @@ public class MainWindowViewModelTests
changed.ShouldContain(nameof(MainWindowViewModel.ConnectionState));
}
/// <summary>
/// Verifies that the shell initializes advanced connection settings with the expected defaults.
/// </summary>
[Fact]
public void DefaultState_HasCorrectAdvancedSettings()
{
@@ -205,6 +256,9 @@ public class MainWindowViewModelTests
_vm.CertificateStorePath.ShouldContain("pki");
}
/// <summary>
/// Verifies that failover endpoint text is parsed into connection settings on connect.
/// </summary>
[Fact]
public async Task ConnectCommand_MapsFailoverUrlsToSettings()
{
@@ -218,6 +272,9 @@ public class MainWindowViewModelTests
_service.LastConnectionSettings.FailoverUrls[1].ShouldBe("opc.tcp://backup2:4840");
}
/// <summary>
/// Verifies that empty failover text is normalized to no configured failover endpoints.
/// </summary>
[Fact]
public async Task ConnectCommand_MapsEmptyFailoverUrlsToNull()
{
@@ -228,6 +285,9 @@ public class MainWindowViewModelTests
_service.LastConnectionSettings!.FailoverUrls.ShouldBeNull();
}
/// <summary>
/// Verifies that the configured session timeout is passed into the connection settings.
/// </summary>
[Fact]
public async Task ConnectCommand_MapsSessionTimeoutToSettings()
{
@@ -238,6 +298,9 @@ public class MainWindowViewModelTests
_service.LastConnectionSettings!.SessionTimeoutSeconds.ShouldBe(120);
}
/// <summary>
/// Verifies that the auto-accept certificate toggle is passed into the connection settings.
/// </summary>
[Fact]
public async Task ConnectCommand_MapsAutoAcceptCertificatesToSettings()
{
@@ -248,6 +311,9 @@ public class MainWindowViewModelTests
_service.LastConnectionSettings!.AutoAcceptCertificates.ShouldBeFalse();
}
/// <summary>
/// Verifies that a custom certificate store path is passed into the connection settings.
/// </summary>
[Fact]
public async Task ConnectCommand_MapsCertificateStorePathToSettings()
{
@@ -258,6 +324,9 @@ public class MainWindowViewModelTests
_service.LastConnectionSettings!.CertificateStorePath.ShouldBe("/custom/pki/path");
}
/// <summary>
/// Verifies that subscribing selected nodes adds subscriptions and switches the shell to the subscriptions tab.
/// </summary>
[Fact]
public async Task SubscribeSelectedNodesCommand_SubscribesAndSwitchesToTab()
{
@@ -275,6 +344,9 @@ public class MainWindowViewModelTests
_vm.SelectedTabIndex.ShouldBe(1);
}
/// <summary>
/// Verifies that subscribing selected nodes is a no-op when nothing is selected.
/// </summary>
[Fact]
public async Task SubscribeSelectedNodesCommand_DoesNothing_WhenNoSelection()
{
@@ -285,6 +357,9 @@ public class MainWindowViewModelTests
_vm.Subscriptions.ActiveSubscriptions.ShouldBeEmpty();
}
/// <summary>
/// Verifies that the history command targets the selected node and switches the shell to the history tab.
/// </summary>
[Fact]
public async Task ViewHistoryForSelectedNodeCommand_SetsNodeAndSwitchesToTab()
{
@@ -299,6 +374,9 @@ public class MainWindowViewModelTests
_vm.SelectedTabIndex.ShouldBe(3);
}
/// <summary>
/// Verifies that history actions are enabled when a variable node is selected.
/// </summary>
[Fact]
public async Task UpdateHistoryEnabledForSelection_TrueForVariableNode()
{
@@ -313,6 +391,9 @@ public class MainWindowViewModelTests
_vm.IsHistoryEnabledForSelection.ShouldBeTrue();
}
/// <summary>
/// Verifies that history actions stay disabled when an object node rather than a variable is selected.
/// </summary>
[Fact]
public async Task UpdateHistoryEnabledForSelection_FalseForObjectNode()
{
@@ -327,6 +408,9 @@ public class MainWindowViewModelTests
_vm.IsHistoryEnabledForSelection.ShouldBeFalse();
}
/// <summary>
/// Verifies that history actions stay disabled when no server connection is active.
/// </summary>
[Fact]
public void UpdateHistoryEnabledForSelection_FalseWhenDisconnected()
{
@@ -335,12 +419,18 @@ public class MainWindowViewModelTests
_vm.IsHistoryEnabledForSelection.ShouldBeFalse();
}
/// <summary>
/// Verifies that saved user settings are loaded during shell construction.
/// </summary>
[Fact]
public void Constructor_LoadsSettingsFromService()
{
_settingsService.LoadCallCount.ShouldBe(1);
}
/// <summary>
/// Verifies that persisted connection and security settings are applied to the shell on startup.
/// </summary>
[Fact]
public void Constructor_AppliesSavedSettings()
{
@@ -376,6 +466,9 @@ public class MainWindowViewModelTests
vm.CertificateStorePath.ShouldBe("/custom/path");
}
/// <summary>
/// Verifies that successful connections persist the current connection settings.
/// </summary>
[Fact]
public async Task ConnectCommand_SavesSettingsOnSuccess()
{
@@ -390,6 +483,9 @@ public class MainWindowViewModelTests
_settingsService.LastSaved.Username.ShouldBe("admin");
}
/// <summary>
/// Verifies that failed connection attempts do not overwrite saved settings.
/// </summary>
[Fact]
public async Task ConnectCommand_DoesNotSaveOnFailure()
{
@@ -400,6 +496,9 @@ public class MainWindowViewModelTests
_settingsService.SaveCallCount.ShouldBe(0);
}
/// <summary>
/// Verifies that active subscriptions are persisted when the shell disconnects.
/// </summary>
[Fact]
public async Task ConnectCommand_SavesSubscribedNodes()
{
@@ -416,6 +515,9 @@ public class MainWindowViewModelTests
_settingsService.LastSaved!.SubscribedNodes.ShouldContain("ns=2;s=TestSub");
}
/// <summary>
/// Verifies that saved subscriptions are restored after reconnecting the shell.
/// </summary>
[Fact]
public async Task ConnectCommand_RestoresSavedSubscriptions()
{
@@ -437,4 +539,4 @@ public class MainWindowViewModelTests
vm.Subscriptions.ActiveSubscriptions[1].NodeId.ShouldBe("ns=2;s=Restored2");
vm.SubscriptionCount.ShouldBe(2);
}
}
}