fix(client-cli): resolve Low code-review findings (Client.CLI-002,003,004,006,007,008,009,010)
- 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>
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
using CliFx.Exceptions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.CLI.Commands;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.Fakes;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for Client.CLI-003: numeric command options must validate their ranges
|
||||
/// and report a clean operator-facing error instead of silently passing bad values to the SDK.
|
||||
/// </summary>
|
||||
public class CommandRangeValidationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task BrowseCommand_NegativeDepth_ThrowsCommandException()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new BrowseCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
Depth = -1
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
var ex = await Should.ThrowAsync<CommandException>(async () => await command.ExecuteAsync(console));
|
||||
ex.Message.ShouldContain("--depth");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BrowseCommand_ZeroDepth_ThrowsCommandException()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new BrowseCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
Depth = 0
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
var ex = await Should.ThrowAsync<CommandException>(async () => await command.ExecuteAsync(console));
|
||||
ex.Message.ShouldContain("--depth");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeCommand_ZeroInterval_ThrowsCommandException()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new SubscribeCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=N",
|
||||
Interval = 0
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
var ex = await Should.ThrowAsync<CommandException>(async () => await command.ExecuteAsync(console));
|
||||
ex.Message.ShouldContain("--interval");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeCommand_NegativeInterval_ThrowsCommandException()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new SubscribeCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=N",
|
||||
Interval = -50
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
await Should.ThrowAsync<CommandException>(async () => await command.ExecuteAsync(console));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeCommand_RecursiveZeroMaxDepth_ThrowsCommandException()
|
||||
{
|
||||
// --max-depth only matters when --recursive is set; validation is scoped to that combination.
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new SubscribeCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=N",
|
||||
Recursive = true,
|
||||
MaxDepth = 0
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
var ex = await Should.ThrowAsync<CommandException>(async () => await command.ExecuteAsync(console));
|
||||
ex.Message.ShouldContain("--max-depth");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeCommand_NegativeDuration_ThrowsCommandException()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new SubscribeCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=N",
|
||||
DurationSeconds = -5
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
await Should.ThrowAsync<CommandException>(async () => await command.ExecuteAsync(console));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AlarmsCommand_ZeroInterval_ThrowsCommandException()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new AlarmsCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
Interval = 0
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
var ex = await Should.ThrowAsync<CommandException>(async () => await command.ExecuteAsync(console));
|
||||
ex.Message.ShouldContain("--interval");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HistoryReadCommand_NegativeMax_ThrowsCommandException()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new HistoryReadCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=N",
|
||||
MaxValues = -10
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
var ex = await Should.ThrowAsync<CommandException>(async () => await command.ExecuteAsync(console));
|
||||
ex.Message.ShouldContain("--max");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HistoryReadCommand_ZeroInterval_ThrowsCommandException()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new HistoryReadCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=N",
|
||||
Aggregate = "Average",
|
||||
IntervalMs = 0
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
var ex = await Should.ThrowAsync<CommandException>(async () => await command.ExecuteAsync(console));
|
||||
ex.Message.ShouldContain("--interval");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.CLI.Commands;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.Fakes;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for Client.CLI-009: long-running commands must detach their event handlers
|
||||
/// from the service before the command finishes, so notifications fired during teardown don't
|
||||
/// land in a disposed console.
|
||||
/// </summary>
|
||||
public class EventHandlerLifecycleTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SubscribeCommand_AfterExit_DataChangedEventHasNoSubscribers()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new SubscribeCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=Node",
|
||||
DurationSeconds = 1
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
await command.ExecuteAsync(console);
|
||||
|
||||
// The fake's event should have no subscribers after the command completes — every
|
||||
// handler attached by the command must have been detached during teardown.
|
||||
fakeService.HasDataChangedSubscribers.ShouldBeFalse(
|
||||
"SubscribeCommand must detach its DataChanged handler before returning.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AlarmsCommand_AfterExit_AlarmEventHasNoSubscribers()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new AlarmsCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840"
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
|
||||
var task = Task.Run(async () => await command.ExecuteAsync(console));
|
||||
|
||||
await Task.Delay(150);
|
||||
console.RequestCancellation();
|
||||
await task;
|
||||
|
||||
fakeService.HasAlarmEventSubscribers.ShouldBeFalse(
|
||||
"AlarmsCommand must detach its AlarmEvent handler before returning.");
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,14 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
|
||||
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),
|
||||
@@ -68,6 +76,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
|
||||
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;
|
||||
@@ -84,6 +93,12 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
|
||||
/// <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)
|
||||
{
|
||||
@@ -120,6 +135,9 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -127,6 +145,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
|
||||
public Task SubscribeAsync(NodeId nodeId, int intervalMs = 1000, CancellationToken ct = default)
|
||||
{
|
||||
SubscribeCalls.Add((nodeId, intervalMs));
|
||||
if (SubscribeException != null) throw SubscribeException;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ public class HistoryReadCommandTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_InvalidAggregate_ThrowsArgumentException()
|
||||
public async Task Execute_InvalidAggregate_ThrowsCommandException()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
@@ -117,7 +117,10 @@ public class HistoryReadCommandTests
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
await Should.ThrowAsync<ArgumentException>(async () => await command.ExecuteAsync(console));
|
||||
// Operator-input errors now surface as CliFx CommandException (was ArgumentException)
|
||||
// per Client.CLI-006 so malformed input prints a clean CLI error instead of a stack trace.
|
||||
await Should.ThrowAsync<CliFx.Exceptions.CommandException>(
|
||||
async () => await command.ExecuteAsync(console));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
using CliFx.Exceptions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.CLI.Commands;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.Fakes;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for Client.CLI-006: predictable operator-input errors must surface as
|
||||
/// CliFx CommandException with a clean message, not raw FormatException/ArgumentException
|
||||
/// with a stack trace.
|
||||
/// </summary>
|
||||
public class InputValidationErrorsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task HistoryReadCommand_InvalidStartTime_ThrowsCommandException()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new HistoryReadCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=N",
|
||||
StartTime = "not-a-date"
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
var ex = await Should.ThrowAsync<CommandException>(async () => await command.ExecuteAsync(console));
|
||||
ex.Message.ShouldContain("--start");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HistoryReadCommand_InvalidEndTime_ThrowsCommandException()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new HistoryReadCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=N",
|
||||
EndTime = "garbage"
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
var ex = await Should.ThrowAsync<CommandException>(async () => await command.ExecuteAsync(console));
|
||||
ex.Message.ShouldContain("--end");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HistoryReadCommand_InvalidAggregate_ThrowsCommandException()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new HistoryReadCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=N",
|
||||
Aggregate = "NotARealAggregate"
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
var ex = await Should.ThrowAsync<CommandException>(async () => await command.ExecuteAsync(console));
|
||||
ex.Message.ShouldContain("aggregate", Case.Insensitive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadCommand_InvalidNodeId_ThrowsCommandException()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new ReadCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "not-a-node-id"
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
var ex = await Should.ThrowAsync<CommandException>(async () => await command.ExecuteAsync(console));
|
||||
ex.Message.ShouldContain("node", Case.Insensitive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeCommand_InvalidNodeId_ThrowsCommandException()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new SubscribeCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "not-a-node-id"
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
var ex = await Should.ThrowAsync<CommandException>(async () => await command.ExecuteAsync(console));
|
||||
ex.Message.ShouldContain("node", Case.Insensitive);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Serilog;
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.CLI.Commands;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.Fakes;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression test for Client.CLI-007: ConfigureLogging must dispose the previously assigned
|
||||
/// Log.Logger before replacing it, so repeated CLI invocations do not leak sinks.
|
||||
/// </summary>
|
||||
public class LoggerLifecycleTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ConfigureLogging_DisposesPreviousLogger_BeforeReassigning()
|
||||
{
|
||||
// Install a tracker logger before the command runs.
|
||||
var trackerSink = new DisposeTrackingSink();
|
||||
var trackerLogger = new LoggerConfiguration()
|
||||
.WriteTo.Sink(trackerSink)
|
||||
.CreateLogger();
|
||||
Log.Logger = trackerLogger;
|
||||
|
||||
try
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new ConnectCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840"
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
await command.ExecuteAsync(console);
|
||||
|
||||
// The command's ConfigureLogging should have disposed the tracker logger we installed.
|
||||
trackerSink.Disposed.ShouldBeTrue(
|
||||
"Previous Log.Logger should be disposed via Log.CloseAndFlush() before ConfigureLogging assigns a new one.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DisposeTrackingSink : ILogEventSink, IDisposable
|
||||
{
|
||||
public bool Disposed { get; private set; }
|
||||
public void Emit(LogEvent logEvent) { }
|
||||
public void Dispose() => Disposed = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.CLI.Commands;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.Fakes;
|
||||
using BrowseResult = ZB.MOM.WW.OtOpcUa.Client.Shared.Models.BrowseResult;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for SubscribeCommand summary bucketing, --duration, --quiet, --summary-file,
|
||||
/// and recursive collection. Covers Client.CLI-002, -009, and -010.
|
||||
/// </summary>
|
||||
public class SubscribeCommandSummaryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Summary_NodeWithNoUpdate_IsCountedAsNeverNotAsNeverWentBad()
|
||||
{
|
||||
// Client.CLI-002: A node that received no updates at all is "never received an update",
|
||||
// NOT a "suspect that never went bad".
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new SubscribeCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=SilentNode",
|
||||
DurationSeconds = 1
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
await command.ExecuteAsync(console);
|
||||
|
||||
var output = TestConsoleHelper.GetOutput(console);
|
||||
output.ShouldContain("No update received at all: 1");
|
||||
output.ShouldContain("NEVER went bad (suspect): 0");
|
||||
// The "suspect" detail header should not appear when the suspect list is empty.
|
||||
output.ShouldNotContain("--- Nodes that NEVER received a bad-quality update (suspect) ---");
|
||||
// The "never received update" detail header should appear.
|
||||
output.ShouldContain("--- Nodes that never received an update at all ---");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Summary_NodeReceivedOnlyGoodValues_IsCountedAsNeverWentBad()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new SubscribeCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=GoodNode",
|
||||
// Use --duration so the command auto-exits.
|
||||
DurationSeconds = 2
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
var runTask = Task.Run(async () => await command.ExecuteAsync(console));
|
||||
|
||||
// Wait until the subscription is registered by the fake.
|
||||
await WaitForAsync(() => fakeService.SubscribeCalls.Count == 1);
|
||||
|
||||
fakeService.RaiseDataChanged(
|
||||
"ns=2;s=GoodNode",
|
||||
new DataValue(new Variant(42), StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow));
|
||||
|
||||
await runTask;
|
||||
|
||||
var output = TestConsoleHelper.GetOutput(console);
|
||||
output.ShouldContain("NEVER went bad (suspect): 1");
|
||||
output.ShouldContain("Last status GOOD: 1");
|
||||
output.ShouldContain("No update received at all: 0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Summary_NodeReceivedBadValue_IsCountedAsEverWentBad()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new SubscribeCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=BadNode",
|
||||
DurationSeconds = 2
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
var runTask = Task.Run(async () => await command.ExecuteAsync(console));
|
||||
|
||||
await WaitForAsync(() => fakeService.SubscribeCalls.Count == 1);
|
||||
|
||||
fakeService.RaiseDataChanged(
|
||||
"ns=2;s=BadNode",
|
||||
new DataValue(Variant.Null, StatusCodes.BadDeviceFailure, DateTime.UtcNow, DateTime.UtcNow));
|
||||
|
||||
await runTask;
|
||||
|
||||
var output = TestConsoleHelper.GetOutput(console);
|
||||
output.ShouldContain("Ever went BAD during window: 1");
|
||||
output.ShouldContain("NEVER went bad (suspect): 0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Duration_ZeroOrPositive_AutoExits()
|
||||
{
|
||||
// --duration > 0 should make the command exit on its own without needing cancellation.
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new SubscribeCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=AutoExit",
|
||||
DurationSeconds = 1
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
await command.ExecuteAsync(console);
|
||||
sw.Stop();
|
||||
|
||||
sw.Elapsed.ShouldBeGreaterThanOrEqualTo(TimeSpan.FromMilliseconds(900));
|
||||
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10));
|
||||
|
||||
var output = TestConsoleHelper.GetOutput(console);
|
||||
output.ShouldContain("==================== SUMMARY ====================");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Quiet_SuppressesPerUpdateOutputButPrintsSummary()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new SubscribeCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=Quiet",
|
||||
Quiet = true,
|
||||
DurationSeconds = 2
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
var runTask = Task.Run(async () => await command.ExecuteAsync(console));
|
||||
|
||||
await WaitForAsync(() => fakeService.SubscribeCalls.Count == 1);
|
||||
fakeService.RaiseDataChanged(
|
||||
"ns=2;s=Quiet",
|
||||
new DataValue(new Variant(1.0), StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow));
|
||||
|
||||
await runTask;
|
||||
|
||||
var output = TestConsoleHelper.GetOutput(console);
|
||||
// No per-update "value = ..." line should appear because --quiet was set.
|
||||
output.ShouldNotContain(" = 1 (");
|
||||
// Summary section is still printed.
|
||||
output.ShouldContain("==================== SUMMARY ====================");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SummaryFile_WritesSummaryToDisk()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var tempFile = Path.Combine(Path.GetTempPath(), $"otopcua-cli-summary-{Guid.NewGuid():N}.txt");
|
||||
try
|
||||
{
|
||||
var command = new SubscribeCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=SummaryFileNode",
|
||||
DurationSeconds = 1,
|
||||
SummaryFile = tempFile
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
await command.ExecuteAsync(console);
|
||||
|
||||
File.Exists(tempFile).ShouldBeTrue();
|
||||
var contents = await File.ReadAllTextAsync(tempFile);
|
||||
contents.ShouldContain("SUMMARY");
|
||||
contents.ShouldContain("Total subscribed: 1");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(tempFile)) File.Delete(tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Recursive_BrowsesSubtreeAndSubscribesEveryVariable()
|
||||
{
|
||||
// Root has a Folder child and a top-level Variable; the Folder contains a second Variable.
|
||||
// Each node id is distinct so the ToDictionary(t => t.nodeId.ToString()) collapse
|
||||
// doesn't trip a duplicate-key error.
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
fakeService.BrowseResultsByParent["ns=2;s=Root"] = new List<BrowseResult>
|
||||
{
|
||||
new("ns=2;s=Folder", "Folder", "Object", true),
|
||||
new("ns=2;s=Tag1", "Tag1", "Variable", false)
|
||||
};
|
||||
fakeService.BrowseResultsByParent["ns=2;s=Folder"] = new List<BrowseResult>
|
||||
{
|
||||
new("ns=2;s=Tag2", "Tag2", "Variable", false)
|
||||
};
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new SubscribeCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=Root",
|
||||
Recursive = true,
|
||||
MaxDepth = 3,
|
||||
DurationSeconds = 1
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
await command.ExecuteAsync(console);
|
||||
|
||||
// Both Variables (depth 1 + depth 2) should have been subscribed.
|
||||
fakeService.SubscribeCalls.Count.ShouldBeGreaterThanOrEqualTo(2);
|
||||
fakeService.SubscribeCalls.ShouldContain(c => c.NodeId.Identifier.ToString() == "Tag1");
|
||||
fakeService.SubscribeCalls.ShouldContain(c => c.NodeId.Identifier.ToString() == "Tag2");
|
||||
|
||||
var output = TestConsoleHelper.GetOutput(console);
|
||||
output.ShouldContain("Browsing subtree of ns=2;s=Root");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeFailure_PrintsFailedMessage_DoesNotCrash()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService
|
||||
{
|
||||
SubscribeException = new InvalidOperationException("forced failure")
|
||||
};
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new SubscribeCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=FailNode",
|
||||
DurationSeconds = 1
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
await command.ExecuteAsync(console);
|
||||
|
||||
var output = TestConsoleHelper.GetOutput(console);
|
||||
output.ShouldContain("FAILED to subscribe");
|
||||
output.ShouldContain("forced failure");
|
||||
// The summary block is still printed.
|
||||
output.ShouldContain("==================== SUMMARY ====================");
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> predicate, int timeoutMs = 5000)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
||||
while (!predicate() && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(25);
|
||||
if (!predicate())
|
||||
throw new TimeoutException("Condition not met within timeout.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user