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:
168
tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/AlarmsCommandTests.cs
Normal file
168
tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/AlarmsCommandTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
146
tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/BrowseCommandTests.cs
Normal file
146
tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/BrowseCommandTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// This file intentionally left empty. Real tests are in separate files.
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
103
tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/WriteCommandTests.cs
Normal file
103
tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/WriteCommandTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user