Add cross-platform OPC UA client stack: shared library, CLI tool, and Avalonia UI

Implements Client.Shared (IOpcUaClientService with connection lifecycle, failover,
browse, read/write, subscriptions, alarms, history, redundancy), Client.CLI (8 CliFx
commands mirroring tools/opcuacli-dotnet), and Client.UI (Avalonia desktop app with
tree browser, read/write, subscriptions, alarms, and history tabs). All three target
.NET 10 and are covered by 249 unit tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-03-30 15:49:42 -04:00
parent 50b85d41bd
commit a2883b82d9
109 changed files with 8571 additions and 0 deletions

View File

@@ -0,0 +1,168 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
using ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests.Fakes;
namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests;
public class AlarmsCommandTests
{
[Fact]
public async Task Execute_SubscribesToAlarms()
{
var fakeService = new FakeOpcUaClientService();
var factory = new FakeOpcUaClientServiceFactory(fakeService);
var command = new AlarmsCommand(factory)
{
Url = "opc.tcp://localhost:4840",
Interval = 2000
};
using var console = TestConsoleHelper.CreateConsole();
var task = Task.Run(async () =>
{
await command.ExecuteAsync(console);
});
await Task.Delay(100);
console.RequestCancellation();
await task;
fakeService.SubscribeAlarmsCalls.Count.ShouldBe(1);
fakeService.SubscribeAlarmsCalls[0].IntervalMs.ShouldBe(2000);
fakeService.SubscribeAlarmsCalls[0].SourceNodeId.ShouldBeNull();
}
[Fact]
public async Task Execute_WithNode_PassesSourceNodeId()
{
var fakeService = new FakeOpcUaClientService();
var factory = new FakeOpcUaClientServiceFactory(fakeService);
var command = new AlarmsCommand(factory)
{
Url = "opc.tcp://localhost:4840",
NodeId = "ns=2;s=AlarmSource"
};
using var console = TestConsoleHelper.CreateConsole();
var task = Task.Run(async () =>
{
await command.ExecuteAsync(console);
});
await Task.Delay(100);
console.RequestCancellation();
await task;
fakeService.SubscribeAlarmsCalls.Count.ShouldBe(1);
fakeService.SubscribeAlarmsCalls[0].SourceNodeId.ShouldNotBeNull();
fakeService.SubscribeAlarmsCalls[0].SourceNodeId!.Identifier.ShouldBe("AlarmSource");
}
[Fact]
public async Task Execute_WithRefresh_RequestsConditionRefresh()
{
var fakeService = new FakeOpcUaClientService();
var factory = new FakeOpcUaClientServiceFactory(fakeService);
var command = new AlarmsCommand(factory)
{
Url = "opc.tcp://localhost:4840",
Refresh = true
};
using var console = TestConsoleHelper.CreateConsole();
var task = Task.Run(async () =>
{
await command.ExecuteAsync(console);
});
await Task.Delay(100);
console.RequestCancellation();
await task;
fakeService.RequestConditionRefreshCalled.ShouldBeTrue();
var output = TestConsoleHelper.GetOutput(console);
output.ShouldContain("Condition refresh requested.");
}
[Fact]
public async Task Execute_RefreshFailure_PrintsError()
{
var fakeService = new FakeOpcUaClientService
{
ConditionRefreshException = new NotSupportedException("Not supported")
};
var factory = new FakeOpcUaClientServiceFactory(fakeService);
var command = new AlarmsCommand(factory)
{
Url = "opc.tcp://localhost:4840",
Refresh = true
};
using var console = TestConsoleHelper.CreateConsole();
var task = Task.Run(async () =>
{
await command.ExecuteAsync(console);
});
await Task.Delay(100);
console.RequestCancellation();
await task;
var output = TestConsoleHelper.GetOutput(console);
output.ShouldContain("Condition refresh not supported:");
}
[Fact]
public async Task Execute_UnsubscribesOnCancellation()
{
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(100);
console.RequestCancellation();
await task;
fakeService.UnsubscribeAlarmsCalled.ShouldBeTrue();
}
[Fact]
public async Task Execute_DisconnectsInFinally()
{
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(100);
console.RequestCancellation();
await task;
fakeService.DisconnectCalled.ShouldBeTrue();
fakeService.DisposeCalled.ShouldBeTrue();
}
}

View File

@@ -0,0 +1,146 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
using ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests.Fakes;
using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
using BrowseResult = ZB.MOM.WW.LmxOpcUa.Client.Shared.Models.BrowseResult;
namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests;
public class BrowseCommandTests
{
[Fact]
public async Task Execute_PrintsBrowseResults()
{
var fakeService = new FakeOpcUaClientService
{
BrowseResults = new List<BrowseResult>
{
new BrowseResult("ns=2;s=Obj1", "Object1", "Object", true),
new BrowseResult("ns=2;s=Var1", "Variable1", "Variable", false),
new BrowseResult("ns=2;s=Meth1", "Method1", "Method", false)
}
};
var factory = new FakeOpcUaClientServiceFactory(fakeService);
var command = new BrowseCommand(factory)
{
Url = "opc.tcp://localhost:4840"
};
using var console = TestConsoleHelper.CreateConsole();
await command.ExecuteAsync(console);
var output = TestConsoleHelper.GetOutput(console);
output.ShouldContain("[Object] Object1 (NodeId: ns=2;s=Obj1)");
output.ShouldContain("[Variable] Variable1 (NodeId: ns=2;s=Var1)");
output.ShouldContain("[Method] Method1 (NodeId: ns=2;s=Meth1)");
}
[Fact]
public async Task Execute_BrowsesFromSpecifiedNode()
{
var fakeService = new FakeOpcUaClientService
{
BrowseResults = new List<BrowseResult>()
};
var factory = new FakeOpcUaClientServiceFactory(fakeService);
var command = new BrowseCommand(factory)
{
Url = "opc.tcp://localhost:4840",
NodeId = "ns=2;s=StartNode"
};
using var console = TestConsoleHelper.CreateConsole();
await command.ExecuteAsync(console);
fakeService.BrowseNodeIds.Count.ShouldBe(1);
fakeService.BrowseNodeIds[0].ShouldNotBeNull();
fakeService.BrowseNodeIds[0]!.Identifier.ShouldBe("StartNode");
}
[Fact]
public async Task Execute_DefaultBrowsesFromNull()
{
var fakeService = new FakeOpcUaClientService
{
BrowseResults = new List<BrowseResult>()
};
var factory = new FakeOpcUaClientServiceFactory(fakeService);
var command = new BrowseCommand(factory)
{
Url = "opc.tcp://localhost:4840"
};
using var console = TestConsoleHelper.CreateConsole();
await command.ExecuteAsync(console);
fakeService.BrowseNodeIds.Count.ShouldBe(1);
fakeService.BrowseNodeIds[0].ShouldBeNull();
}
[Fact]
public async Task Execute_NonRecursive_BrowsesSingleLevel()
{
var fakeService = new FakeOpcUaClientService
{
BrowseResults = new List<BrowseResult>
{
new BrowseResult("ns=2;s=Child", "Child", "Object", true)
}
};
var factory = new FakeOpcUaClientServiceFactory(fakeService);
var command = new BrowseCommand(factory)
{
Url = "opc.tcp://localhost:4840",
Depth = 5 // Should be ignored without recursive flag
};
using var console = TestConsoleHelper.CreateConsole();
await command.ExecuteAsync(console);
// Only the root level browse should happen, not child
fakeService.BrowseNodeIds.Count.ShouldBe(1);
}
[Fact]
public async Task Execute_Recursive_BrowsesChildren()
{
var fakeService = new FakeOpcUaClientService();
// Override browse to return children only on first call
// We can't easily do this with the simple fake, but the default returns results with HasChildren=true
// which will trigger child browse with recursive=true, depth=2
var factory = new FakeOpcUaClientServiceFactory(fakeService);
var command = new BrowseCommand(factory)
{
Url = "opc.tcp://localhost:4840",
Recursive = true,
Depth = 2
};
using var console = TestConsoleHelper.CreateConsole();
await command.ExecuteAsync(console);
// Root browse + child browse (for Node1 which HasChildren=true)
fakeService.BrowseNodeIds.Count.ShouldBeGreaterThan(1);
}
[Fact]
public async Task Execute_DisconnectsInFinally()
{
var fakeService = new FakeOpcUaClientService
{
BrowseResults = new List<BrowseResult>()
};
var factory = new FakeOpcUaClientServiceFactory(fakeService);
var command = new BrowseCommand(factory)
{
Url = "opc.tcp://localhost:4840"
};
using var console = TestConsoleHelper.CreateConsole();
await command.ExecuteAsync(console);
fakeService.DisconnectCalled.ShouldBeTrue();
fakeService.DisposeCalled.ShouldBeTrue();
}
}

View File

@@ -0,0 +1,90 @@
using CliFx.Infrastructure;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
using ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests.Fakes;
using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests;
public class CommandBaseTests
{
[Fact]
public async Task CommonOptions_MapToConnectionSettings_Correctly()
{
var fakeService = new FakeOpcUaClientService();
var factory = new FakeOpcUaClientServiceFactory(fakeService);
var command = new ConnectCommand(factory)
{
Url = "opc.tcp://myserver:4840",
Username = "admin",
Password = "secret",
Security = "sign",
FailoverUrls = "opc.tcp://backup1:4840,opc.tcp://backup2:4840"
};
using var console = TestConsoleHelper.CreateConsole();
await command.ExecuteAsync(console);
var settings = fakeService.LastConnectionSettings;
settings.ShouldNotBeNull();
settings.EndpointUrl.ShouldBe("opc.tcp://myserver:4840");
settings.Username.ShouldBe("admin");
settings.Password.ShouldBe("secret");
settings.SecurityMode.ShouldBe(SecurityMode.Sign);
settings.FailoverUrls.ShouldNotBeNull();
settings.FailoverUrls!.Length.ShouldBe(3); // primary + 2 failover
settings.FailoverUrls[0].ShouldBe("opc.tcp://myserver:4840");
settings.AutoAcceptCertificates.ShouldBeTrue();
}
[Fact]
public async Task SecurityOption_Encrypt_MapsToSignAndEncrypt()
{
var fakeService = new FakeOpcUaClientService();
var factory = new FakeOpcUaClientServiceFactory(fakeService);
var command = new ConnectCommand(factory)
{
Url = "opc.tcp://localhost:4840",
Security = "encrypt"
};
using var console = TestConsoleHelper.CreateConsole();
await command.ExecuteAsync(console);
fakeService.LastConnectionSettings!.SecurityMode.ShouldBe(SecurityMode.SignAndEncrypt);
}
[Fact]
public async Task SecurityOption_None_MapsToNone()
{
var fakeService = new FakeOpcUaClientService();
var factory = new FakeOpcUaClientServiceFactory(fakeService);
var command = new ConnectCommand(factory)
{
Url = "opc.tcp://localhost:4840",
Security = "none"
};
using var console = TestConsoleHelper.CreateConsole();
await command.ExecuteAsync(console);
fakeService.LastConnectionSettings!.SecurityMode.ShouldBe(SecurityMode.None);
}
[Fact]
public async Task NoFailoverUrls_FailoverUrlsIsNull()
{
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);
fakeService.LastConnectionSettings!.FailoverUrls.ShouldBeNull();
}
}

View File

@@ -0,0 +1,78 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
using ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests.Fakes;
using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests;
public class ConnectCommandTests
{
[Fact]
public async Task Execute_PrintsConnectionInfo()
{
var fakeService = new FakeOpcUaClientService
{
ConnectionInfoResult = new ConnectionInfo(
"opc.tcp://testhost:4840",
"MyServer",
"SignAndEncrypt",
"http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256",
"session-42",
"MySession")
};
var factory = new FakeOpcUaClientServiceFactory(fakeService);
var command = new ConnectCommand(factory)
{
Url = "opc.tcp://testhost:4840"
};
using var console = TestConsoleHelper.CreateConsole();
await command.ExecuteAsync(console);
var output = TestConsoleHelper.GetOutput(console);
output.ShouldContain("Connected to: opc.tcp://testhost:4840");
output.ShouldContain("Server: MyServer");
output.ShouldContain("Security Mode: SignAndEncrypt");
output.ShouldContain("Security Policy: http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256");
output.ShouldContain("Connection successful.");
}
[Fact]
public async Task Execute_CallsConnectAndDisconnect()
{
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);
fakeService.ConnectCalled.ShouldBeTrue();
fakeService.DisconnectCalled.ShouldBeTrue();
fakeService.DisposeCalled.ShouldBeTrue();
}
[Fact]
public async Task Execute_DisconnectsOnError()
{
var fakeService = new FakeOpcUaClientService
{
ConnectException = new InvalidOperationException("Connection refused")
};
var factory = new FakeOpcUaClientServiceFactory(fakeService);
var command = new ConnectCommand(factory)
{
Url = "opc.tcp://localhost:4840"
};
using var console = TestConsoleHelper.CreateConsole();
// The command should propagate the exception but still clean up.
// Since connect fails, service is null in finally, so no disconnect.
await Should.ThrowAsync<InvalidOperationException>(
async () => await command.ExecuteAsync(console));
}
}

View File

@@ -0,0 +1,184 @@
using Opc.Ua;
using ZB.MOM.WW.LmxOpcUa.Client.Shared;
using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
using BrowseResult = ZB.MOM.WW.LmxOpcUa.Client.Shared.Models.BrowseResult;
namespace ZB.MOM.WW.LmxOpcUa.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; } = new();
public List<(NodeId NodeId, object Value)> WriteValues { get; } = new();
public List<NodeId?> BrowseNodeIds { get; } = new();
public List<(NodeId NodeId, int IntervalMs)> SubscribeCalls { get; } = new();
public List<NodeId> UnsubscribeCalls { get; } = new();
public List<(NodeId? SourceNodeId, int IntervalMs)> SubscribeAlarmsCalls { get; } = new();
public bool UnsubscribeAlarmsCalled { get; private set; }
public bool RequestConditionRefreshCalled { get; private set; }
public List<(NodeId NodeId, DateTime Start, DateTime End, int MaxValues)> HistoryReadRawCalls { get; } = new();
public List<(NodeId NodeId, DateTime Start, DateTime End, AggregateType Aggregate, double IntervalMs)> HistoryReadAggregateCalls { get; } = new();
public bool GetRedundancyInfoCalled { get; private set; }
// Configurable results
public ConnectionInfo ConnectionInfoResult { get; set; } = new ConnectionInfo(
"opc.tcp://localhost:4840",
"TestServer",
"None",
"http://opcfoundation.org/UA/SecurityPolicy#None",
"session-1",
"TestSession");
public DataValue ReadValueResult { get; set; } = new DataValue(
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 BrowseResult("ns=2;s=Node1", "Node1", "Object", true),
new BrowseResult("ns=2;s=Node2", "Node2", "Variable", false)
};
public IReadOnlyList<DataValue> HistoryReadResult { get; set; } = new List<DataValue>
{
new DataValue(new Variant(10.0), StatusCodes.Good, DateTime.UtcNow.AddHours(-1), DateTime.UtcNow),
new DataValue(new Variant(20.0), StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow)
};
public RedundancyInfo RedundancyInfoResult { get; set; } = new RedundancyInfo(
"Warm", 200, new[] { "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; }
// IOpcUaClientService implementation
public bool IsConnected => ConnectCalled && !DisconnectCalled;
public ConnectionInfo? CurrentConnectionInfo => ConnectCalled ? ConnectionInfoResult : null;
public event EventHandler<DataChangedEventArgs>? DataChanged;
public event EventHandler<AlarmEventArgs>? AlarmEvent;
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
public Task<ConnectionInfo> ConnectAsync(ConnectionSettings settings, CancellationToken ct = default)
{
ConnectCalled = true;
LastConnectionSettings = settings;
if (ConnectException != null) throw ConnectException;
return Task.FromResult(ConnectionInfoResult);
}
public Task DisconnectAsync(CancellationToken ct = default)
{
DisconnectCalled = true;
return Task.CompletedTask;
}
public Task<DataValue> ReadValueAsync(NodeId nodeId, CancellationToken ct = default)
{
ReadNodeIds.Add(nodeId);
if (ReadException != null) throw ReadException;
return Task.FromResult(ReadValueResult);
}
public Task<StatusCode> WriteValueAsync(NodeId nodeId, object value, CancellationToken ct = default)
{
WriteValues.Add((nodeId, value));
if (WriteException != null) throw WriteException;
return Task.FromResult(WriteStatusCodeResult);
}
public Task<IReadOnlyList<BrowseResult>> BrowseAsync(NodeId? parentNodeId = null, CancellationToken ct = default)
{
BrowseNodeIds.Add(parentNodeId);
return Task.FromResult(BrowseResults);
}
public Task SubscribeAsync(NodeId nodeId, int intervalMs = 1000, CancellationToken ct = default)
{
SubscribeCalls.Add((nodeId, intervalMs));
return Task.CompletedTask;
}
public Task UnsubscribeAsync(NodeId nodeId, CancellationToken ct = default)
{
UnsubscribeCalls.Add(nodeId);
return Task.CompletedTask;
}
public Task SubscribeAlarmsAsync(NodeId? sourceNodeId = null, int intervalMs = 1000, CancellationToken ct = default)
{
SubscribeAlarmsCalls.Add((sourceNodeId, intervalMs));
return Task.CompletedTask;
}
public Task UnsubscribeAlarmsAsync(CancellationToken ct = default)
{
UnsubscribeAlarmsCalled = true;
return Task.CompletedTask;
}
public Task RequestConditionRefreshAsync(CancellationToken ct = default)
{
RequestConditionRefreshCalled = true;
if (ConditionRefreshException != null) throw ConditionRefreshException;
return Task.CompletedTask;
}
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);
}
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);
}
public Task<RedundancyInfo> GetRedundancyInfoAsync(CancellationToken ct = default)
{
GetRedundancyInfoCalled = true;
return Task.FromResult(RedundancyInfoResult);
}
/// <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));
}
public void Dispose()
{
DisposeCalled = true;
}
}

View File

@@ -0,0 +1,18 @@
using ZB.MOM.WW.LmxOpcUa.Client.Shared;
namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests.Fakes;
/// <summary>
/// Fake factory that returns a pre-configured <see cref="FakeOpcUaClientService"/> for testing.
/// </summary>
public sealed class FakeOpcUaClientServiceFactory : IOpcUaClientServiceFactory
{
private readonly FakeOpcUaClientService _service;
public FakeOpcUaClientServiceFactory(FakeOpcUaClientService service)
{
_service = service;
}
public IOpcUaClientService Create() => _service;
}

View File

@@ -0,0 +1,141 @@
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
using ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests.Fakes;
using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests;
public class HistoryReadCommandTests
{
[Fact]
public async Task Execute_RawRead_PrintsValues()
{
var time1 = new DateTime(2025, 6, 15, 10, 0, 0, DateTimeKind.Utc);
var time2 = new DateTime(2025, 6, 15, 11, 0, 0, DateTimeKind.Utc);
var fakeService = new FakeOpcUaClientService
{
HistoryReadResult = new List<DataValue>
{
new DataValue(new Variant(10.5), StatusCodes.Good, time1, time1),
new DataValue(new Variant(20.3), StatusCodes.Good, time2, time2)
}
};
var factory = new FakeOpcUaClientServiceFactory(fakeService);
var command = new HistoryReadCommand(factory)
{
Url = "opc.tcp://localhost:4840",
NodeId = "ns=2;s=HistNode",
StartTime = "2025-06-15T00:00:00Z",
EndTime = "2025-06-15T23:59:59Z"
};
using var console = TestConsoleHelper.CreateConsole();
await command.ExecuteAsync(console);
var output = TestConsoleHelper.GetOutput(console);
output.ShouldContain("History for ns=2;s=HistNode");
output.ShouldContain("Timestamp");
output.ShouldContain("Value");
output.ShouldContain("Status");
output.ShouldContain("2 values returned.");
}
[Fact]
public async Task Execute_RawRead_CallsHistoryReadRaw()
{
var fakeService = new FakeOpcUaClientService();
var factory = new FakeOpcUaClientServiceFactory(fakeService);
var command = new HistoryReadCommand(factory)
{
Url = "opc.tcp://localhost:4840",
NodeId = "ns=2;s=HistNode",
MaxValues = 500
};
using var console = TestConsoleHelper.CreateConsole();
await command.ExecuteAsync(console);
fakeService.HistoryReadRawCalls.Count.ShouldBe(1);
fakeService.HistoryReadRawCalls[0].MaxValues.ShouldBe(500);
fakeService.HistoryReadRawCalls[0].NodeId.Identifier.ShouldBe("HistNode");
}
[Fact]
public async Task Execute_AggregateRead_CallsHistoryReadAggregate()
{
var fakeService = new FakeOpcUaClientService();
var factory = new FakeOpcUaClientServiceFactory(fakeService);
var command = new HistoryReadCommand(factory)
{
Url = "opc.tcp://localhost:4840",
NodeId = "ns=2;s=HistNode",
Aggregate = "Average",
IntervalMs = 60000
};
using var console = TestConsoleHelper.CreateConsole();
await command.ExecuteAsync(console);
fakeService.HistoryReadAggregateCalls.Count.ShouldBe(1);
fakeService.HistoryReadAggregateCalls[0].Aggregate.ShouldBe(AggregateType.Average);
fakeService.HistoryReadAggregateCalls[0].IntervalMs.ShouldBe(60000);
}
[Fact]
public async Task Execute_AggregateRead_PrintsAggregateInfo()
{
var fakeService = new FakeOpcUaClientService();
var factory = new FakeOpcUaClientServiceFactory(fakeService);
var command = new HistoryReadCommand(factory)
{
Url = "opc.tcp://localhost:4840",
NodeId = "ns=2;s=HistNode",
Aggregate = "Maximum",
IntervalMs = 7200000
};
using var console = TestConsoleHelper.CreateConsole();
await command.ExecuteAsync(console);
var output = TestConsoleHelper.GetOutput(console);
output.ShouldContain("Maximum");
output.ShouldContain("7200000");
}
[Fact]
public async Task Execute_InvalidAggregate_ThrowsArgumentException()
{
var fakeService = new FakeOpcUaClientService();
var factory = new FakeOpcUaClientServiceFactory(fakeService);
var command = new HistoryReadCommand(factory)
{
Url = "opc.tcp://localhost:4840",
NodeId = "ns=2;s=HistNode",
Aggregate = "InvalidAgg"
};
using var console = TestConsoleHelper.CreateConsole();
await Should.ThrowAsync<ArgumentException>(
async () => await command.ExecuteAsync(console));
}
[Fact]
public async Task Execute_DisconnectsInFinally()
{
var fakeService = new FakeOpcUaClientService();
var factory = new FakeOpcUaClientServiceFactory(fakeService);
var command = new HistoryReadCommand(factory)
{
Url = "opc.tcp://localhost:4840",
NodeId = "ns=2;s=HistNode"
};
using var console = TestConsoleHelper.CreateConsole();
await command.ExecuteAsync(console);
fakeService.DisconnectCalled.ShouldBeTrue();
fakeService.DisposeCalled.ShouldBeTrue();
}
}

View File

@@ -0,0 +1,87 @@
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LmxOpcUa.Client.CLI.Helpers;
namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests;
public class NodeIdParserTests
{
[Fact]
public void Parse_NullInput_ReturnsNull()
{
NodeIdParser.Parse(null).ShouldBeNull();
}
[Fact]
public void Parse_EmptyString_ReturnsNull()
{
NodeIdParser.Parse("").ShouldBeNull();
}
[Fact]
public void Parse_WhitespaceOnly_ReturnsNull()
{
NodeIdParser.Parse(" ").ShouldBeNull();
}
[Fact]
public void Parse_StandardStringFormat_ReturnsNodeId()
{
var result = NodeIdParser.Parse("ns=2;s=MyNode");
result.ShouldNotBeNull();
result.NamespaceIndex.ShouldBe((ushort)2);
result.Identifier.ShouldBe("MyNode");
}
[Fact]
public void Parse_NumericFormat_ReturnsNodeId()
{
var result = NodeIdParser.Parse("i=85");
result.ShouldNotBeNull();
result.IdType.ShouldBe(IdType.Numeric);
}
[Fact]
public void Parse_BareNumeric_ReturnsNamespace0NumericNodeId()
{
var result = NodeIdParser.Parse("85");
result.ShouldNotBeNull();
result.NamespaceIndex.ShouldBe((ushort)0);
result.Identifier.ShouldBe((uint)85);
}
[Fact]
public void Parse_WithWhitespacePadding_Trims()
{
var result = NodeIdParser.Parse(" ns=2;s=MyNode ");
result.ShouldNotBeNull();
result.Identifier.ShouldBe("MyNode");
}
[Fact]
public void Parse_InvalidFormat_ThrowsFormatException()
{
Should.Throw<FormatException>(() => NodeIdParser.Parse("not-a-node-id"));
}
[Fact]
public void ParseRequired_NullInput_ThrowsArgumentException()
{
Should.Throw<ArgumentException>(() => NodeIdParser.ParseRequired(null));
}
[Fact]
public void ParseRequired_EmptyInput_ThrowsArgumentException()
{
Should.Throw<ArgumentException>(() => NodeIdParser.ParseRequired(""));
}
[Fact]
public void ParseRequired_ValidInput_ReturnsNodeId()
{
var result = NodeIdParser.ParseRequired("ns=2;s=TestNode");
result.ShouldNotBeNull();
result.Identifier.ShouldBe("TestNode");
}
}

View File

@@ -0,0 +1 @@
// This file intentionally left empty. Real tests are in separate files.

View File

@@ -0,0 +1,99 @@
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
using ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests.Fakes;
namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests;
public class ReadCommandTests
{
[Fact]
public async Task Execute_PrintsReadValue()
{
var sourceTime = new DateTime(2025, 6, 15, 10, 30, 0, DateTimeKind.Utc);
var serverTime = new DateTime(2025, 6, 15, 10, 30, 1, DateTimeKind.Utc);
var fakeService = new FakeOpcUaClientService
{
ReadValueResult = new DataValue(
new Variant("Hello"),
StatusCodes.Good,
sourceTime,
serverTime)
};
var factory = new FakeOpcUaClientServiceFactory(fakeService);
var command = new ReadCommand(factory)
{
Url = "opc.tcp://localhost:4840",
NodeId = "ns=2;s=TestNode"
};
using var console = TestConsoleHelper.CreateConsole();
await command.ExecuteAsync(console);
var output = TestConsoleHelper.GetOutput(console);
output.ShouldContain("Node: ns=2;s=TestNode");
output.ShouldContain("Value: Hello");
output.ShouldContain("Status:");
output.ShouldContain("Source Time:");
output.ShouldContain("Server Time:");
}
[Fact]
public async Task Execute_CallsReadValueWithCorrectNodeId()
{
var fakeService = new FakeOpcUaClientService();
var factory = new FakeOpcUaClientServiceFactory(fakeService);
var command = new ReadCommand(factory)
{
Url = "opc.tcp://localhost:4840",
NodeId = "ns=2;s=MyVariable"
};
using var console = TestConsoleHelper.CreateConsole();
await command.ExecuteAsync(console);
fakeService.ReadNodeIds.Count.ShouldBe(1);
fakeService.ReadNodeIds[0].Identifier.ShouldBe("MyVariable");
}
[Fact]
public async Task Execute_DisconnectsInFinally()
{
var fakeService = new FakeOpcUaClientService();
var factory = new FakeOpcUaClientServiceFactory(fakeService);
var command = new ReadCommand(factory)
{
Url = "opc.tcp://localhost:4840",
NodeId = "ns=2;s=TestNode"
};
using var console = TestConsoleHelper.CreateConsole();
await command.ExecuteAsync(console);
fakeService.DisconnectCalled.ShouldBeTrue();
fakeService.DisposeCalled.ShouldBeTrue();
}
[Fact]
public async Task Execute_DisconnectsEvenOnReadError()
{
var fakeService = new FakeOpcUaClientService
{
ReadException = new InvalidOperationException("Read failed")
};
var factory = new FakeOpcUaClientServiceFactory(fakeService);
var command = new ReadCommand(factory)
{
Url = "opc.tcp://localhost:4840",
NodeId = "ns=2;s=TestNode"
};
using var console = TestConsoleHelper.CreateConsole();
await Should.ThrowAsync<InvalidOperationException>(
async () => await command.ExecuteAsync(console));
fakeService.DisconnectCalled.ShouldBeTrue();
fakeService.DisposeCalled.ShouldBeTrue();
}
}

View File

@@ -0,0 +1,93 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
using ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests.Fakes;
using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests;
public class RedundancyCommandTests
{
[Fact]
public async Task Execute_PrintsRedundancyInfo()
{
var fakeService = new FakeOpcUaClientService
{
RedundancyInfoResult = new RedundancyInfo(
"Hot", 250, new[] { "urn:server:primary", "urn:server:secondary" }, "urn:app:myserver")
};
var factory = new FakeOpcUaClientServiceFactory(fakeService);
var command = new RedundancyCommand(factory)
{
Url = "opc.tcp://localhost:4840"
};
using var console = TestConsoleHelper.CreateConsole();
await command.ExecuteAsync(console);
var output = TestConsoleHelper.GetOutput(console);
output.ShouldContain("Redundancy Mode: Hot");
output.ShouldContain("Service Level: 250");
output.ShouldContain("Server URIs:");
output.ShouldContain(" - urn:server:primary");
output.ShouldContain(" - urn:server:secondary");
output.ShouldContain("Application URI: urn:app:myserver");
}
[Fact]
public async Task Execute_NoServerUris_OmitsUriSection()
{
var fakeService = new FakeOpcUaClientService
{
RedundancyInfoResult = new RedundancyInfo(
"None", 100, Array.Empty<string>(), "urn:app:standalone")
};
var factory = new FakeOpcUaClientServiceFactory(fakeService);
var command = new RedundancyCommand(factory)
{
Url = "opc.tcp://localhost:4840"
};
using var console = TestConsoleHelper.CreateConsole();
await command.ExecuteAsync(console);
var output = TestConsoleHelper.GetOutput(console);
output.ShouldContain("Redundancy Mode: None");
output.ShouldContain("Service Level: 100");
output.ShouldNotContain("Server URIs:");
output.ShouldContain("Application URI: urn:app:standalone");
}
[Fact]
public async Task Execute_CallsGetRedundancyInfo()
{
var fakeService = new FakeOpcUaClientService();
var factory = new FakeOpcUaClientServiceFactory(fakeService);
var command = new RedundancyCommand(factory)
{
Url = "opc.tcp://localhost:4840"
};
using var console = TestConsoleHelper.CreateConsole();
await command.ExecuteAsync(console);
fakeService.GetRedundancyInfoCalled.ShouldBeTrue();
}
[Fact]
public async Task Execute_DisconnectsInFinally()
{
var fakeService = new FakeOpcUaClientService();
var factory = new FakeOpcUaClientServiceFactory(fakeService);
var command = new RedundancyCommand(factory)
{
Url = "opc.tcp://localhost:4840"
};
using var console = TestConsoleHelper.CreateConsole();
await command.ExecuteAsync(console);
fakeService.DisconnectCalled.ShouldBeTrue();
fakeService.DisposeCalled.ShouldBeTrue();
}
}

View File

@@ -0,0 +1,120 @@
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
using ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests.Fakes;
namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests;
public class SubscribeCommandTests
{
[Fact]
public async Task Execute_SubscribesWithCorrectParameters()
{
var fakeService = new FakeOpcUaClientService();
var factory = new FakeOpcUaClientServiceFactory(fakeService);
var command = new SubscribeCommand(factory)
{
Url = "opc.tcp://localhost:4840",
NodeId = "ns=2;s=TestVar",
Interval = 500
};
using var console = TestConsoleHelper.CreateConsole();
// The subscribe command waits for cancellation. We need to cancel it.
// Use the console's cancellation to trigger stop.
var task = Task.Run(async () =>
{
await command.ExecuteAsync(console);
});
// Give it a moment to subscribe, then cancel
await Task.Delay(100);
console.RequestCancellation();
await task;
fakeService.SubscribeCalls.Count.ShouldBe(1);
fakeService.SubscribeCalls[0].IntervalMs.ShouldBe(500);
fakeService.SubscribeCalls[0].NodeId.Identifier.ShouldBe("TestVar");
}
[Fact]
public async Task Execute_UnsubscribesOnCancellation()
{
var fakeService = new FakeOpcUaClientService();
var factory = new FakeOpcUaClientServiceFactory(fakeService);
var command = new SubscribeCommand(factory)
{
Url = "opc.tcp://localhost:4840",
NodeId = "ns=2;s=TestVar"
};
using var console = TestConsoleHelper.CreateConsole();
var task = Task.Run(async () =>
{
await command.ExecuteAsync(console);
});
await Task.Delay(100);
console.RequestCancellation();
await task;
fakeService.UnsubscribeCalls.Count.ShouldBe(1);
}
[Fact]
public async Task Execute_DisconnectsInFinally()
{
var fakeService = new FakeOpcUaClientService();
var factory = new FakeOpcUaClientServiceFactory(fakeService);
var command = new SubscribeCommand(factory)
{
Url = "opc.tcp://localhost:4840",
NodeId = "ns=2;s=TestVar"
};
using var console = TestConsoleHelper.CreateConsole();
var task = Task.Run(async () =>
{
await command.ExecuteAsync(console);
});
await Task.Delay(100);
console.RequestCancellation();
await task;
fakeService.DisconnectCalled.ShouldBeTrue();
fakeService.DisposeCalled.ShouldBeTrue();
}
[Fact]
public async Task Execute_PrintsSubscriptionMessage()
{
var fakeService = new FakeOpcUaClientService();
var factory = new FakeOpcUaClientServiceFactory(fakeService);
var command = new SubscribeCommand(factory)
{
Url = "opc.tcp://localhost:4840",
NodeId = "ns=2;s=TestVar",
Interval = 2000
};
using var console = TestConsoleHelper.CreateConsole();
var task = Task.Run(async () =>
{
await command.ExecuteAsync(console);
});
await Task.Delay(100);
console.RequestCancellation();
await task;
var output = TestConsoleHelper.GetOutput(console);
output.ShouldContain("Subscribed to ns=2;s=TestVar (interval: 2000ms)");
output.ShouldContain("Unsubscribed.");
}
}

View File

@@ -0,0 +1,35 @@
using CliFx.Infrastructure;
namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests;
/// <summary>
/// Helper for creating CliFx <see cref="FakeInMemoryConsole"/> instances and reading their output.
/// </summary>
public static class TestConsoleHelper
{
/// <summary>
/// Creates a new <see cref="FakeInMemoryConsole"/> for testing.
/// </summary>
public static FakeInMemoryConsole CreateConsole()
{
return new FakeInMemoryConsole();
}
/// <summary>
/// Reads all text written to the console's standard output.
/// </summary>
public static string GetOutput(FakeInMemoryConsole console)
{
console.Output.Flush();
return console.ReadOutputString();
}
/// <summary>
/// Reads all text written to the console's standard error.
/// </summary>
public static string GetError(FakeInMemoryConsole console)
{
console.Error.Flush();
return console.ReadErrorString();
}
}

View File

@@ -0,0 +1,103 @@
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
using ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests.Fakes;
namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests;
public class WriteCommandTests
{
[Fact]
public async Task Execute_WritesSuccessfully()
{
var fakeService = new FakeOpcUaClientService
{
ReadValueResult = new DataValue(new Variant(42)),
WriteStatusCodeResult = StatusCodes.Good
};
var factory = new FakeOpcUaClientServiceFactory(fakeService);
var command = new WriteCommand(factory)
{
Url = "opc.tcp://localhost:4840",
NodeId = "ns=2;s=MyVar",
Value = "100"
};
using var console = TestConsoleHelper.CreateConsole();
await command.ExecuteAsync(console);
var output = TestConsoleHelper.GetOutput(console);
output.ShouldContain("Write successful: ns=2;s=MyVar = 100");
}
[Fact]
public async Task Execute_ReportsFailure()
{
var fakeService = new FakeOpcUaClientService
{
ReadValueResult = new DataValue(new Variant("current")),
WriteStatusCodeResult = StatusCodes.BadNotWritable
};
var factory = new FakeOpcUaClientServiceFactory(fakeService);
var command = new WriteCommand(factory)
{
Url = "opc.tcp://localhost:4840",
NodeId = "ns=2;s=ReadOnly",
Value = "newvalue"
};
using var console = TestConsoleHelper.CreateConsole();
await command.ExecuteAsync(console);
var output = TestConsoleHelper.GetOutput(console);
output.ShouldContain("Write failed:");
}
[Fact]
public async Task Execute_ReadsCurrentValueThenWrites()
{
var fakeService = new FakeOpcUaClientService
{
ReadValueResult = new DataValue(new Variant(3.14)),
WriteStatusCodeResult = StatusCodes.Good
};
var factory = new FakeOpcUaClientServiceFactory(fakeService);
var command = new WriteCommand(factory)
{
Url = "opc.tcp://localhost:4840",
NodeId = "ns=2;s=FloatVar",
Value = "2.718"
};
using var console = TestConsoleHelper.CreateConsole();
await command.ExecuteAsync(console);
// Should read first to get current type, then write
fakeService.ReadNodeIds.Count.ShouldBe(1);
fakeService.WriteValues.Count.ShouldBe(1);
fakeService.WriteValues[0].Value.ShouldBeOfType<double>();
}
[Fact]
public async Task Execute_DisconnectsInFinally()
{
var fakeService = new FakeOpcUaClientService
{
ReadValueResult = new DataValue(new Variant("test"))
};
var factory = new FakeOpcUaClientServiceFactory(fakeService);
var command = new WriteCommand(factory)
{
Url = "opc.tcp://localhost:4840",
NodeId = "ns=2;s=Node",
Value = "value"
};
using var console = TestConsoleHelper.CreateConsole();
await command.ExecuteAsync(console);
fakeService.DisconnectCalled.ShouldBeTrue();
fakeService.DisposeCalled.ShouldBeTrue();
}
}

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="1.1.0" />
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="CliFx" Version="2.3.6" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.LmxOpcUa.Client.CLI\ZB.MOM.WW.LmxOpcUa.Client.CLI.csproj" />
</ItemGroup>
</Project>