using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
///
/// Integration tests for the F10b production binding: boot a real
/// through , attach a ,
/// drive WriteValue/WriteAlarmState/RebuildAddressSpace, and verify the
/// reflects the writes.
///
public sealed class SdkAddressSpaceSinkTests : IDisposable
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private readonly string _pkiRoot = Path.Combine(
Path.GetTempPath(),
$"otopcua-sink-{Guid.NewGuid():N}");
[Fact]
public async Task WriteValue_creates_and_updates_variable_in_node_manager()
{
var (host, server) = await BootAsync();
var sink = new SdkAddressSpaceSink(server.NodeManager!);
sink.WriteValue("eq-1/temp", 22.5, OpcUaQuality.Good, DateTime.UtcNow);
sink.WriteValue("eq-1/temp", 23.1, OpcUaQuality.Good, DateTime.UtcNow);
sink.WriteValue("eq-1/pressure", 100, OpcUaQuality.Uncertain, DateTime.UtcNow);
server.NodeManager!.VariableCount.ShouldBe(2);
await host.DisposeAsync();
}
[Fact]
public async Task WriteAlarmState_creates_dedicated_node_distinct_from_value_writes()
{
var (host, server) = await BootAsync();
var sink = new SdkAddressSpaceSink(server.NodeManager!);
sink.WriteAlarmState("alarm-7", active: true, acknowledged: false, DateTime.UtcNow);
sink.WriteValue("eq-1/temp", 22.5, OpcUaQuality.Good, DateTime.UtcNow);
server.NodeManager!.VariableCount.ShouldBe(2);
await host.DisposeAsync();
}
[Fact]
public async Task RebuildAddressSpace_clears_all_registered_variables()
{
var (host, server) = await BootAsync();
var sink = new SdkAddressSpaceSink(server.NodeManager!);
sink.WriteValue("a", 1, OpcUaQuality.Good, DateTime.UtcNow);
sink.WriteValue("b", 2, OpcUaQuality.Good, DateTime.UtcNow);
sink.WriteAlarmState("alarm-c", true, false, DateTime.UtcNow);
server.NodeManager!.VariableCount.ShouldBe(3);
sink.RebuildAddressSpace();
server.NodeManager.VariableCount.ShouldBe(0);
// After rebuild, subsequent writes start fresh.
sink.WriteValue("a", 99, OpcUaQuality.Good, DateTime.UtcNow);
server.NodeManager.VariableCount.ShouldBe(1);
await host.DisposeAsync();
}
[Fact]
public async Task NullOpcUaAddressSpaceSink_does_not_crash_on_any_call()
{
// Sanity check that the F10 fallback still works — production callers default to
// NullOpcUaAddressSpaceSink when no SDK NodeManager is wired.
var sink = NullOpcUaAddressSpaceSink.Instance;
sink.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow);
sink.WriteAlarmState("a", true, false, DateTime.UtcNow);
sink.RebuildAddressSpace();
await Task.CompletedTask;
}
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
{
var host = new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.SinkTest",
ApplicationUri = $"urn:OtOpcUa.SinkTest:{Guid.NewGuid():N}",
OpcUaPort = AllocateFreePort(),
PublicHostname = "localhost",
PkiStoreRoot = _pkiRoot,
},
NullLogger.Instance);
var server = new OtOpcUaSdkServer();
await host.StartAsync(server, Ct);
return (host, server);
}
private static int AllocateFreePort()
{
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
listener.Start();
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
public void Dispose()
{
if (Directory.Exists(_pkiRoot))
{
try { Directory.Delete(_pkiRoot, recursive: true); }
catch { /* best-effort cleanup */ }
}
}
}