Second protocol plugin proving multi-protocol architecture. New Junction.Protocols.OpcUa (multi-target net48;net8.0 — SDK has no ns2.0): - OpcuaDriverFactory: config validation (EndpointUrl/SecurityMode/Policy/ AuthMode/Username/Password/NodeIds/BrowseRoot/TimeoutSeconds), CONFIG_* codes. - OpcuaDriver: ReadCurrentAsync (read configured nodes) + ProbeAsync (browse address space), linked-CTS timeout, Result mapping, opt-in FilterToMonitored — mirrors MtconnectDriver. - IUaClient seam (SDK-free) injected for testability; UaClient wraps OPC UA Session (non-obsolete async API), Directory PKI store, anon/user-pass auth. SDK: OPCFoundation.NetStandard.Opc.Ua.Client 1.5.378.156 (MIT), pinned exact, transitive closure copied per-TFM into plugins/opcua/ (CopyLocalLockFileAssemblies + CopyOpcuaPlugin target). Host: AutoGenerateBindingRedirects. Core untouched — protocol auto-discovered by plugin loader. Projects: +Junction.Protocols.OpcUa, Junction.App (wiring), Junction.sln, Junction.Tests (+23 tests: 13 factory + 10 driver via Mock<IUaClient>). 171 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
218 lines
8.9 KiB
C#
218 lines
8.9 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Junction.Domain.Models;
|
|
using Junction.Domain.Protocols;
|
|
using Junction.Protocols.OpcUa;
|
|
using Moq;
|
|
using Xunit;
|
|
|
|
namespace Junction.Tests.Unit
|
|
{
|
|
/// <summary>
|
|
/// Drives <see cref="OpcuaDriver"/> against a mocked <see cref="IUaClient"/> (no live server),
|
|
/// mirroring MtconnectDriverTests. Exercises the read/browse happy paths, the opt-in selection
|
|
/// filter, transport-failure mapping and cancellation.
|
|
/// </summary>
|
|
public sealed class OpcuaDriverTests
|
|
{
|
|
private static readonly Guid MachineId = Guid.Parse("11111111-2222-3333-4444-555555555555");
|
|
private const string BrowseRoot = "i=85";
|
|
private static readonly TimeSpan GenerousTimeout = TimeSpan.FromSeconds(30);
|
|
|
|
// Node ids configured for ReadCurrentAsync.
|
|
private static readonly string[] ConfiguredNodes = { "ns=2;s=Temp", "ns=2;s=Speed", "ns=2;s=State" };
|
|
|
|
private static IReadOnlyList<UaReadResult> FakeReads() => new List<UaReadResult>
|
|
{
|
|
new UaReadResult("ns=2;s=Temp", "21.5", true, DateTimeOffset.UtcNow),
|
|
new UaReadResult("ns=2;s=Speed", "1500", true, DateTimeOffset.UtcNow),
|
|
new UaReadResult("ns=2;s=State", "RUNNING", true, DateTimeOffset.UtcNow),
|
|
};
|
|
|
|
private static OpcuaDriver DriverWith(
|
|
IUaClient client,
|
|
IReadOnlyList<string>? nodeIds = null,
|
|
IReadOnlyCollection<string>? monitoredItemIds = null,
|
|
TimeSpan? timeout = null) =>
|
|
new OpcuaDriver(
|
|
client,
|
|
MachineId,
|
|
nodeIds ?? ConfiguredNodes,
|
|
BrowseRoot,
|
|
timeout ?? GenerousTimeout,
|
|
monitoredItemIds ?? ConfiguredNodes);
|
|
|
|
[Fact]
|
|
public void ProtocolId_IsOpcua()
|
|
{
|
|
var driver = DriverWith(new Mock<IUaClient>().Object);
|
|
Assert.Equal("opcua", driver.ProtocolId);
|
|
}
|
|
|
|
// ---- ReadCurrentAsync: happy path ----
|
|
|
|
[Fact]
|
|
public async Task ReadCurrentAsync_ReadsConfiguredNodes_ReturnsConnectedSnapshotStampedWithMachineId()
|
|
{
|
|
var mock = new Mock<IUaClient>();
|
|
mock.Setup(c => c.ConnectAsync(It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
|
|
mock.Setup(c => c.ReadAsync(It.IsAny<IReadOnlyList<string>>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(FakeReads());
|
|
|
|
var driver = DriverWith(mock.Object);
|
|
var result = await driver.ReadCurrentAsync(CancellationToken.None);
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.False(result.WasCancelled);
|
|
Assert.Equal(MachineId, result.Value.MachineId);
|
|
Assert.Equal(ConnectionState.Connected, result.Value.ConnectionState);
|
|
Assert.Equal(3, result.Value.Items.Count);
|
|
Assert.Contains(result.Value.Items, i => i.Id == "ns=2;s=Temp" && i.Value == "21.5");
|
|
mock.Verify(c => c.ReadAsync(It.IsAny<IReadOnlyList<string>>(), It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
// ---- ReadCurrentAsync: opt-in selection filter ----
|
|
|
|
[Fact]
|
|
public async Task ReadCurrentAsync_SelectionSubset_KeepsOnlySelectedItems_PreservesConnectionState()
|
|
{
|
|
var mock = new Mock<IUaClient>();
|
|
mock.Setup(c => c.ReadAsync(It.IsAny<IReadOnlyList<string>>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(FakeReads());
|
|
|
|
var selection = new[] { "ns=2;s=Temp", "ns=2;s=State" };
|
|
var driver = DriverWith(mock.Object, monitoredItemIds: selection);
|
|
var result = await driver.ReadCurrentAsync(CancellationToken.None);
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(2, result.Value.Items.Count);
|
|
Assert.Contains(result.Value.Items, i => i.Id == "ns=2;s=Temp");
|
|
Assert.Contains(result.Value.Items, i => i.Id == "ns=2;s=State");
|
|
Assert.DoesNotContain(result.Value.Items, i => i.Id == "ns=2;s=Speed");
|
|
Assert.Equal(ConnectionState.Connected, result.Value.ConnectionState);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadCurrentAsync_EmptySelection_ReturnsEmptyItems_PreservesConnectionState()
|
|
{
|
|
var mock = new Mock<IUaClient>();
|
|
mock.Setup(c => c.ReadAsync(It.IsAny<IReadOnlyList<string>>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(FakeReads());
|
|
|
|
var driver = DriverWith(mock.Object, monitoredItemIds: Array.Empty<string>());
|
|
var result = await driver.ReadCurrentAsync(CancellationToken.None);
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Empty(result.Value.Items);
|
|
Assert.Equal(ConnectionState.Connected, result.Value.ConnectionState);
|
|
}
|
|
|
|
// ---- ReadCurrentAsync: failure + cancellation ----
|
|
|
|
[Fact]
|
|
public async Task ReadCurrentAsync_ClientThrows_ReturnsFailNoThrow()
|
|
{
|
|
var mock = new Mock<IUaClient>();
|
|
mock.Setup(c => c.ReadAsync(It.IsAny<IReadOnlyList<string>>(), It.IsAny<CancellationToken>()))
|
|
.ThrowsAsync(new InvalidOperationException("server unreachable"));
|
|
|
|
var driver = DriverWith(mock.Object);
|
|
var result = await driver.ReadCurrentAsync(CancellationToken.None);
|
|
|
|
Assert.False(result.IsSuccess);
|
|
Assert.False(result.WasCancelled);
|
|
Assert.Contains(result.Errors, e => e.Code == "CURRENT_READ_ERROR");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadCurrentAsync_CancelledBeforeCall_ReturnsCancelled()
|
|
{
|
|
var mock = new Mock<IUaClient>();
|
|
var driver = DriverWith(mock.Object);
|
|
using var cts = new CancellationTokenSource();
|
|
cts.Cancel();
|
|
|
|
var result = await driver.ReadCurrentAsync(cts.Token);
|
|
|
|
Assert.False(result.IsSuccess);
|
|
Assert.True(result.WasCancelled);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadCurrentAsync_CancelledDuringCall_ReturnsCancelledNoThrow()
|
|
{
|
|
var mock = new Mock<IUaClient>();
|
|
using var cts = new CancellationTokenSource();
|
|
mock.Setup(c => c.ReadAsync(It.IsAny<IReadOnlyList<string>>(), It.IsAny<CancellationToken>()))
|
|
.Returns<IReadOnlyList<string>, CancellationToken>(async (_, ct) =>
|
|
{
|
|
cts.Cancel();
|
|
await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
|
|
return FakeReads();
|
|
});
|
|
|
|
var driver = DriverWith(mock.Object);
|
|
var result = await driver.ReadCurrentAsync(cts.Token);
|
|
|
|
Assert.False(result.IsSuccess);
|
|
Assert.True(result.WasCancelled);
|
|
}
|
|
|
|
// ---- ProbeAsync: catalog ----
|
|
|
|
[Fact]
|
|
public async Task ProbeAsync_BrowsesFromRoot_ReturnsDescriptors()
|
|
{
|
|
var descriptors = new List<DataItemDescriptor>
|
|
{
|
|
new DataItemDescriptor("ns=2;s=Temp", "Temperature", "Double", "VARIABLE", ""),
|
|
new DataItemDescriptor("ns=2;s=Speed", "Speed", "Int32", "VARIABLE", ""),
|
|
};
|
|
var mock = new Mock<IUaClient>();
|
|
mock.Setup(c => c.BrowseAsync(BrowseRoot, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(descriptors);
|
|
|
|
var driver = DriverWith(mock.Object);
|
|
var result = await driver.ProbeAsync(CancellationToken.None);
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(2, result.Value.Count);
|
|
var temp = result.Value.Single(d => d.Id == "ns=2;s=Temp");
|
|
Assert.Equal("Temperature", temp.Name);
|
|
Assert.Equal("Double", temp.Type);
|
|
Assert.Equal("VARIABLE", temp.Category);
|
|
mock.Verify(c => c.BrowseAsync(BrowseRoot, It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ProbeAsync_ClientThrows_ReturnsFailNoThrow()
|
|
{
|
|
var mock = new Mock<IUaClient>();
|
|
mock.Setup(c => c.BrowseAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ThrowsAsync(new InvalidOperationException("browse failed"));
|
|
|
|
var driver = DriverWith(mock.Object);
|
|
var result = await driver.ProbeAsync(CancellationToken.None);
|
|
|
|
Assert.False(result.IsSuccess);
|
|
Assert.Contains(result.Errors, e => e.Code == "PROBE_BROWSE_ERROR");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ProbeAsync_CancelledBeforeCall_ReturnsCancelled()
|
|
{
|
|
var mock = new Mock<IUaClient>();
|
|
var driver = DriverWith(mock.Object);
|
|
using var cts = new CancellationTokenSource();
|
|
cts.Cancel();
|
|
|
|
var result = await driver.ProbeAsync(cts.Token);
|
|
|
|
Assert.False(result.IsSuccess);
|
|
Assert.True(result.WasCancelled);
|
|
}
|
|
}
|
|
}
|