junction/tests/Junction.Tests/Unit/OpcuaDriverTests.cs
dtrentin 88dfa74ebe feat: OPC UA protocol plugin (v0.4)
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>
2026-07-22 07:44:23 +02:00

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);
}
}
}