- Client.CLI-002: SubscribeCommand's neverWentBad list now requires the node to be present in lastStatus (i.e. received at least one update) so the 'suspect' bucket only contains observed nodes. - Client.CLI-003: every long-running command validates numeric option ranges (Interval / Depth / MaxDepth / Duration / Max) and throws CliFx CommandException on out-of-range values. - Client.CLI-004: SubscribeCommand carries XML summary docs on the type, ctor, every [CommandOption] property, and ExecuteAsync — matching the sibling commands' style. - Client.CLI-006: HistoryReadCommand parses --start / --end with InvariantCulture+UTC and surfaces FormatException as CommandException; every NodeIdParser.ParseRequired call wraps FormatException / ArgumentException as CommandException. - Client.CLI-007: CommandBase.ConfigureLogging calls Log.CloseAndFlush() before assigning a new Log.Logger so prior sinks are disposed. - Client.CLI-008: rewrote the subscribe and historyread sections of docs/Client.CLI.md (every flag documented, summary-bucket vocabulary, StandardDeviation aggregate, UTC --start/--end convention). - Client.CLI-009: SubscribeCommand / AlarmsCommand use named local handlers and detach them via -= after UnsubscribeAsync so no notification reaches the console after the command's output phase ends. - Client.CLI-010: added CommandRangeValidationTests, EventHandlerLifecycleTests, InputValidationErrorsTests, LoggerLifecycleTests, and SubscribeCommandSummaryTests pinning every Low fix; FakeOpcUaClientService gained AddDiscoveredVariable + RaiseDataChanged + BrowseResultsByParent helpers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
238 lines
8.9 KiB
C#
238 lines
8.9 KiB
C#
using Opc.Ua;
|
|
using ZB.MOM.WW.OtOpcUa.Client.Shared;
|
|
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
|
|
using BrowseResult = ZB.MOM.WW.OtOpcUa.Client.Shared.Models.BrowseResult;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.Fakes;
|
|
|
|
/// <summary>
|
|
/// Fake implementation of <see cref="IOpcUaClientService" /> for unit testing commands.
|
|
/// Records all method calls and returns configurable results.
|
|
/// </summary>
|
|
public sealed class FakeOpcUaClientService : IOpcUaClientService
|
|
{
|
|
// Track calls
|
|
public bool ConnectCalled { get; private set; }
|
|
public ConnectionSettings? LastConnectionSettings { get; private set; }
|
|
public bool DisconnectCalled { get; private set; }
|
|
public bool DisposeCalled { get; private set; }
|
|
public List<NodeId> ReadNodeIds { get; } = [];
|
|
public List<(NodeId NodeId, object Value)> WriteValues { get; } = [];
|
|
public List<NodeId?> BrowseNodeIds { get; } = [];
|
|
public List<(NodeId NodeId, int IntervalMs)> SubscribeCalls { get; } = [];
|
|
public List<NodeId> UnsubscribeCalls { get; } = [];
|
|
public List<(NodeId? SourceNodeId, int IntervalMs)> SubscribeAlarmsCalls { get; } = [];
|
|
public bool UnsubscribeAlarmsCalled { get; private set; }
|
|
public bool RequestConditionRefreshCalled { get; private set; }
|
|
public List<(NodeId NodeId, DateTime Start, DateTime End, int MaxValues)> HistoryReadRawCalls { get; } = [];
|
|
|
|
public List<(NodeId NodeId, DateTime Start, DateTime End, AggregateType Aggregate, double IntervalMs)>
|
|
HistoryReadAggregateCalls { get; } =
|
|
[];
|
|
|
|
public bool GetRedundancyInfoCalled { get; private set; }
|
|
|
|
// Configurable results
|
|
public ConnectionInfo ConnectionInfoResult { get; set; } = new(
|
|
"opc.tcp://localhost:4840",
|
|
"TestServer",
|
|
"None",
|
|
"http://opcfoundation.org/UA/SecurityPolicy#None",
|
|
"session-1",
|
|
"TestSession");
|
|
|
|
public DataValue ReadValueResult { get; set; } = new(
|
|
new Variant(42),
|
|
StatusCodes.Good,
|
|
DateTime.UtcNow,
|
|
DateTime.UtcNow);
|
|
|
|
public StatusCode WriteStatusCodeResult { get; set; } = StatusCodes.Good;
|
|
|
|
public IReadOnlyList<BrowseResult> BrowseResults { get; set; } = new List<BrowseResult>
|
|
{
|
|
new("ns=2;s=Node1", "Node1", "Object", true),
|
|
new("ns=2;s=Node2", "Node2", "Variable", false)
|
|
};
|
|
|
|
/// <summary>
|
|
/// Optional per-parent-node browse results. When a key matches the requested parent's
|
|
/// <see cref="NodeId.ToString" />, this dictionary takes precedence over <see cref="BrowseResults" />.
|
|
/// Tests exercising recursive walks (Client.CLI-010) use it to model a real subtree whose
|
|
/// child node ids do not collide on descent.
|
|
/// </summary>
|
|
public Dictionary<string, IReadOnlyList<BrowseResult>> BrowseResultsByParent { get; } = new();
|
|
|
|
public IReadOnlyList<DataValue> HistoryReadResult { get; set; } = new List<DataValue>
|
|
{
|
|
new(new Variant(10.0), StatusCodes.Good, DateTime.UtcNow.AddHours(-1), DateTime.UtcNow),
|
|
new(new Variant(20.0), StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow)
|
|
};
|
|
|
|
public RedundancyInfo RedundancyInfoResult { get; set; } = new(
|
|
"Warm", 200, ["urn:server1", "urn:server2"], "urn:app:test");
|
|
|
|
public Exception? ConnectException { get; set; }
|
|
public Exception? ReadException { get; set; }
|
|
public Exception? WriteException { get; set; }
|
|
public Exception? ConditionRefreshException { get; set; }
|
|
public Exception? SubscribeException { get; set; }
|
|
|
|
/// <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;
|
|
|
|
/// <summary>True when at least one handler is attached to <see cref="DataChanged" />.</summary>
|
|
public bool HasDataChangedSubscribers => DataChanged != null;
|
|
|
|
/// <summary>True when at least one handler is attached to <see cref="AlarmEvent" />.</summary>
|
|
public bool HasAlarmEventSubscribers => AlarmEvent != null;
|
|
|
|
/// <inheritdoc />
|
|
public Task<ConnectionInfo> ConnectAsync(ConnectionSettings settings, CancellationToken ct = default)
|
|
{
|
|
ConnectCalled = true;
|
|
LastConnectionSettings = settings;
|
|
if (ConnectException != null) throw ConnectException;
|
|
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);
|
|
if (ReadException != null) throw ReadException;
|
|
return Task.FromResult(ReadValueResult);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<StatusCode> WriteValueAsync(NodeId nodeId, object value, CancellationToken ct = default)
|
|
{
|
|
WriteValues.Add((nodeId, value));
|
|
if (WriteException != null) throw WriteException;
|
|
return Task.FromResult(WriteStatusCodeResult);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<IReadOnlyList<BrowseResult>> BrowseAsync(NodeId? parentNodeId = null, CancellationToken ct = default)
|
|
{
|
|
BrowseNodeIds.Add(parentNodeId);
|
|
var key = parentNodeId?.ToString();
|
|
if (key != null && BrowseResultsByParent.TryGetValue(key, out var keyed))
|
|
return Task.FromResult(keyed);
|
|
return Task.FromResult(BrowseResults);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task SubscribeAsync(NodeId nodeId, int intervalMs = 1000, CancellationToken ct = default)
|
|
{
|
|
SubscribeCalls.Add((nodeId, intervalMs));
|
|
if (SubscribeException != null) throw SubscribeException;
|
|
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;
|
|
if (ConditionRefreshException != null) throw ConditionRefreshException;
|
|
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)
|
|
{
|
|
HistoryReadRawCalls.Add((nodeId, startTime, endTime, maxValues));
|
|
return Task.FromResult(HistoryReadResult);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<IReadOnlyList<DataValue>> HistoryReadAggregateAsync(
|
|
NodeId nodeId, DateTime startTime, DateTime endTime, AggregateType aggregate,
|
|
double intervalMs = 3600000, CancellationToken ct = default)
|
|
{
|
|
HistoryReadAggregateCalls.Add((nodeId, startTime, endTime, aggregate, intervalMs));
|
|
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;
|
|
}
|
|
|
|
/// <summary>Raises the DataChanged event for testing subscribe commands.</summary>
|
|
public void RaiseDataChanged(string nodeId, DataValue value)
|
|
{
|
|
DataChanged?.Invoke(this, new DataChangedEventArgs(nodeId, value));
|
|
}
|
|
|
|
/// <summary>Raises the AlarmEvent for testing alarm commands.</summary>
|
|
public void RaiseAlarmEvent(AlarmEventArgs args)
|
|
{
|
|
AlarmEvent?.Invoke(this, args);
|
|
}
|
|
|
|
/// <summary>Raises the ConnectionStateChanged event for testing.</summary>
|
|
public void RaiseConnectionStateChanged(ConnectionState oldState, ConnectionState newState, string endpointUrl)
|
|
{
|
|
ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(oldState, newState, endpointUrl));
|
|
}
|
|
}
|