Phase 0 — mechanical rename ZB.MOM.WW.LmxOpcUa.* → ZB.MOM.WW.OtOpcUa.*

Renames all 11 projects (5 src + 6 tests), the .slnx solution file, all source-file namespaces, all axaml namespace references, and all v1 documentation references in CLAUDE.md and docs/*.md (excluding docs/v2/ which is already in OtOpcUa form). Also updates the TopShelf service registration name from "LmxOpcUa" to "OtOpcUa" per Phase 0 Task 0.6.

Preserves runtime identifiers per Phase 0 Out-of-Scope rules to avoid breaking v1/v2 client trust during coexistence: OPC UA `ApplicationUri` defaults (`urn:{GalaxyName}:LmxOpcUa`), server `EndpointPath` (`/LmxOpcUa`), `ServerName` default (feeds cert subject CN), `MxAccessConfiguration.ClientName` default (defensive — stays "LmxOpcUa" for MxAccess audit-trail consistency), client OPC UA identifiers (`ApplicationName = "LmxOpcUaClient"`, `ApplicationUri = "urn:localhost:LmxOpcUaClient"`, cert directory `%LocalAppData%\LmxOpcUaClient\pki\`), and the `LmxOpcUaServer` class name (class rename out of Phase 0 scope per Task 0.5 sed pattern; happens in Phase 1 alongside `LmxNodeManager → GenericDriverNodeManager` Core extraction). 23 LmxOpcUa references retained, all enumerated and justified in `docs/v2/implementation/exit-gate-phase-0.md`.

Build clean: 0 errors, 30 warnings (lower than baseline 167). Tests at strict improvement over baseline: 821 passing / 1 failing vs baseline 820 / 2 (one flaky pre-existing failure passed this run; the other still fails — both pre-existing and unrelated to the rename). `Client.UI.Tests`, `Historian.Aveva.Tests`, `Client.Shared.Tests`, `IntegrationTests` all match baseline exactly. Exit gate compliance results recorded in `docs/v2/implementation/exit-gate-phase-0.md` with all 7 checks PASS or DEFERRED-to-PR-review (#7 service install verification needs Windows service permissions on the reviewer's box).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-17 13:57:47 -04:00
parent 5b8d708c58
commit 3b2defd94f
293 changed files with 841 additions and 722 deletions

View File

@@ -0,0 +1,150 @@
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;
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,145 @@
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;
public class BrowseCommandTests
{
[Fact]
public async Task Execute_PrintsBrowseResults()
{
var fakeService = new FakeOpcUaClientService
{
BrowseResults = new List<BrowseResult>
{
new("ns=2;s=Obj1", "Object1", "Object", true),
new("ns=2;s=Var1", "Variable1", "Variable", false),
new("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("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,89 @@
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;
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,77 @@
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;
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,218 @@
using Opc.Ua;
using ZB.MOM.WW.OtOpcUa.Client.Shared;
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
using BrowseResult = ZB.MOM.WW.OtOpcUa.Client.Shared.Models.BrowseResult;
namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.Fakes;
/// <summary>
/// Fake implementation of <see cref="IOpcUaClientService" /> for unit testing commands.
/// Records all method calls and returns configurable results.
/// </summary>
public sealed class FakeOpcUaClientService : IOpcUaClientService
{
// Track calls
public bool ConnectCalled { get; private set; }
public ConnectionSettings? LastConnectionSettings { get; private set; }
public bool DisconnectCalled { get; private set; }
public bool DisposeCalled { get; private set; }
public List<NodeId> ReadNodeIds { get; } = [];
public List<(NodeId NodeId, object Value)> WriteValues { get; } = [];
public List<NodeId?> BrowseNodeIds { get; } = [];
public List<(NodeId NodeId, int IntervalMs)> SubscribeCalls { get; } = [];
public List<NodeId> UnsubscribeCalls { get; } = [];
public List<(NodeId? SourceNodeId, int IntervalMs)> SubscribeAlarmsCalls { get; } = [];
public bool UnsubscribeAlarmsCalled { get; private set; }
public bool RequestConditionRefreshCalled { get; private set; }
public List<(NodeId NodeId, DateTime Start, DateTime End, int MaxValues)> HistoryReadRawCalls { get; } = [];
public List<(NodeId NodeId, DateTime Start, DateTime End, AggregateType Aggregate, double IntervalMs)>
HistoryReadAggregateCalls { get; } =
[];
public bool GetRedundancyInfoCalled { get; private set; }
// Configurable results
public ConnectionInfo ConnectionInfoResult { get; set; } = new(
"opc.tcp://localhost:4840",
"TestServer",
"None",
"http://opcfoundation.org/UA/SecurityPolicy#None",
"session-1",
"TestSession");
public DataValue ReadValueResult { get; set; } = new(
new Variant(42),
StatusCodes.Good,
DateTime.UtcNow,
DateTime.UtcNow);
public StatusCode WriteStatusCodeResult { get; set; } = StatusCodes.Good;
public IReadOnlyList<BrowseResult> BrowseResults { get; set; } = new List<BrowseResult>
{
new("ns=2;s=Node1", "Node1", "Object", true),
new("ns=2;s=Node2", "Node2", "Variable", false)
};
public IReadOnlyList<DataValue> HistoryReadResult { get; set; } = new List<DataValue>
{
new(new Variant(10.0), StatusCodes.Good, DateTime.UtcNow.AddHours(-1), DateTime.UtcNow),
new(new Variant(20.0), StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow)
};
public RedundancyInfo RedundancyInfoResult { get; set; } = new(
"Warm", 200, ["urn:server1", "urn:server2"], "urn:app:test");
public Exception? ConnectException { get; set; }
public Exception? ReadException { get; set; }
public Exception? WriteException { get; set; }
public Exception? ConditionRefreshException { get; set; }
/// <inheritdoc />
public bool IsConnected => ConnectCalled && !DisconnectCalled;
/// <inheritdoc />
public ConnectionInfo? CurrentConnectionInfo => ConnectCalled ? ConnectionInfoResult : null;
/// <inheritdoc />
public event EventHandler<DataChangedEventArgs>? DataChanged;
/// <inheritdoc />
public event EventHandler<AlarmEventArgs>? AlarmEvent;
/// <inheritdoc />
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
/// <inheritdoc />
public Task<ConnectionInfo> ConnectAsync(ConnectionSettings settings, CancellationToken ct = default)
{
ConnectCalled = true;
LastConnectionSettings = settings;
if (ConnectException != null) throw ConnectException;
return Task.FromResult(ConnectionInfoResult);
}
/// <inheritdoc />
public Task DisconnectAsync(CancellationToken ct = default)
{
DisconnectCalled = true;
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<DataValue> ReadValueAsync(NodeId nodeId, CancellationToken ct = default)
{
ReadNodeIds.Add(nodeId);
if (ReadException != null) throw ReadException;
return Task.FromResult(ReadValueResult);
}
/// <inheritdoc />
public Task<StatusCode> WriteValueAsync(NodeId nodeId, object value, CancellationToken ct = default)
{
WriteValues.Add((nodeId, value));
if (WriteException != null) throw WriteException;
return Task.FromResult(WriteStatusCodeResult);
}
/// <inheritdoc />
public Task<IReadOnlyList<BrowseResult>> BrowseAsync(NodeId? parentNodeId = null, CancellationToken ct = default)
{
BrowseNodeIds.Add(parentNodeId);
return Task.FromResult(BrowseResults);
}
/// <inheritdoc />
public Task SubscribeAsync(NodeId nodeId, int intervalMs = 1000, CancellationToken ct = default)
{
SubscribeCalls.Add((nodeId, intervalMs));
return Task.CompletedTask;
}
/// <inheritdoc />
public Task UnsubscribeAsync(NodeId nodeId, CancellationToken ct = default)
{
UnsubscribeCalls.Add(nodeId);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task SubscribeAlarmsAsync(NodeId? sourceNodeId = null, int intervalMs = 1000, CancellationToken ct = default)
{
SubscribeAlarmsCalls.Add((sourceNodeId, intervalMs));
return Task.CompletedTask;
}
/// <inheritdoc />
public Task UnsubscribeAlarmsAsync(CancellationToken ct = default)
{
UnsubscribeAlarmsCalled = true;
return Task.CompletedTask;
}
/// <inheritdoc />
public Task RequestConditionRefreshAsync(CancellationToken ct = default)
{
RequestConditionRefreshCalled = true;
if (ConditionRefreshException != null) throw ConditionRefreshException;
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<StatusCode> AcknowledgeAlarmAsync(string conditionNodeId, byte[] eventId, string comment,
CancellationToken ct = default)
{
return Task.FromResult(new StatusCode(StatusCodes.Good));
}
/// <inheritdoc />
public Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(
NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues = 1000, CancellationToken ct = default)
{
HistoryReadRawCalls.Add((nodeId, startTime, endTime, maxValues));
return Task.FromResult(HistoryReadResult);
}
/// <inheritdoc />
public Task<IReadOnlyList<DataValue>> HistoryReadAggregateAsync(
NodeId nodeId, DateTime startTime, DateTime endTime, AggregateType aggregate,
double intervalMs = 3600000, CancellationToken ct = default)
{
HistoryReadAggregateCalls.Add((nodeId, startTime, endTime, aggregate, intervalMs));
return Task.FromResult(HistoryReadResult);
}
/// <inheritdoc />
public Task<RedundancyInfo> GetRedundancyInfoAsync(CancellationToken ct = default)
{
GetRedundancyInfoCalled = true;
return Task.FromResult(RedundancyInfoResult);
}
/// <summary>
/// Marks the fake client as disposed so CLI command tests can assert cleanup behavior.
/// </summary>
public void Dispose()
{
DisposeCalled = true;
}
/// <summary>Raises the DataChanged event for testing subscribe commands.</summary>
public void RaiseDataChanged(string nodeId, DataValue value)
{
DataChanged?.Invoke(this, new DataChangedEventArgs(nodeId, value));
}
/// <summary>Raises the AlarmEvent for testing alarm commands.</summary>
public void RaiseAlarmEvent(AlarmEventArgs args)
{
AlarmEvent?.Invoke(this, args);
}
/// <summary>Raises the ConnectionStateChanged event for testing.</summary>
public void RaiseConnectionStateChanged(ConnectionState oldState, ConnectionState newState, string endpointUrl)
{
ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(oldState, newState, endpointUrl));
}
}

View File

@@ -0,0 +1,21 @@
using ZB.MOM.WW.OtOpcUa.Client.Shared;
namespace ZB.MOM.WW.OtOpcUa.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()
{
return _service;
}
}

View File

@@ -0,0 +1,140 @@
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;
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(new Variant(10.5), StatusCodes.Good, time1, time1),
new(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.OtOpcUa.Client.CLI.Helpers;
namespace ZB.MOM.WW.OtOpcUa.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,2 @@
// This file intentionally left empty. Real tests are in separate files.

View File

@@ -0,0 +1,98 @@
using Opc.Ua;
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;
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.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;
public class RedundancyCommandTests
{
[Fact]
public async Task Execute_PrintsRedundancyInfo()
{
var fakeService = new FakeOpcUaClientService
{
RedundancyInfoResult = new RedundancyInfo(
"Hot", 250, ["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, [], "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,107 @@
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;
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.OtOpcUa.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.OtOpcUa.Client.CLI.Commands;
using ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.Fakes;
namespace ZB.MOM.WW.OtOpcUa.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.OtOpcUa.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.OtOpcUa.Client.CLI\ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
</ItemGroup>
</Project>