214 lines
9.5 KiB
C#
214 lines
9.5 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Opc.Ua;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
|
|
|
/// <summary>
|
|
/// Integration tests for the F10b production binding: boot a real <see cref="OtOpcUaSdkServer"/>
|
|
/// through <see cref="OpcUaApplicationHost"/>, attach a <see cref="SdkAddressSpaceSink"/>,
|
|
/// drive <c>WriteValue</c>/<c>WriteAlarmState</c>/<c>RebuildAddressSpace</c>, and verify the
|
|
/// <see cref="OtOpcUaNodeManager"/> reflects the writes.
|
|
/// </summary>
|
|
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}");
|
|
|
|
/// <summary>Verifies that WriteValue creates and updates variables in the OPC UA node manager.</summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>Verifies that WriteAlarmState creates a dedicated node distinct from value writes.</summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>Verifies that RebuildAddressSpace clears all registered variables.</summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>Verifies that NullOpcUaAddressSpaceSink does not crash on any call.</summary>
|
|
[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;
|
|
}
|
|
|
|
/// <summary>T14 — materialises an equipment folder + a real Part 9 AlarmConditionState under it,
|
|
/// then projects active state through WriteAlarmState. Asserts the node is a real
|
|
/// <see cref="AlarmConditionState"/>, reachable under the equipment folder, and that
|
|
/// ActiveState/Retain reflect the write. Also inspects which optional Part 9 children
|
|
/// <c>Create</c> auto-builds (the T13 uncertainty) and records the finding inline.</summary>
|
|
[Fact]
|
|
public async Task MaterialiseAlarmCondition_creates_real_condition_node_and_WriteAlarmState_updates_it()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
var nm = server.NodeManager!;
|
|
var sink = new SdkAddressSpaceSink(nm);
|
|
|
|
// Equipment folder must exist first (MaterialiseHierarchy owns this in production).
|
|
sink.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
|
|
|
|
// Materialise the condition. NodeId == alarm node id (the ScriptedAlarmId) so WriteAlarmState targets it.
|
|
sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700);
|
|
|
|
nm.AlarmConditionCount.ShouldBe(1);
|
|
|
|
var condition = nm.TryGetAlarmCondition("alm-1");
|
|
condition.ShouldNotBeNull();
|
|
// It is a REAL Part 9 alarm condition (subtype mapped from "OffNormalAlarm").
|
|
condition.ShouldBeOfType<OffNormalAlarmState>();
|
|
condition.NodeId.ShouldBe(new NodeId("alm-1", nm.NamespaceIndex));
|
|
|
|
// Reachable under the equipment folder: the parent is the eq-1 folder (HasComponent child).
|
|
condition.Parent.ShouldNotBeNull();
|
|
condition.Parent!.NodeId.ShouldBe(new NodeId("eq-1", nm.NamespaceIndex));
|
|
|
|
// Initial state set by MaterialiseAlarmCondition: enabled, inactive, acked, retain=false.
|
|
condition.EnabledState.Id.Value.ShouldBeTrue();
|
|
condition.ActiveState.Id.Value.ShouldBeFalse();
|
|
condition.Retain.Value.ShouldBeFalse();
|
|
|
|
// --- T13 optional-children finding (RESOLVED by THIS real-server test) ---
|
|
// AckedState is mandatory on AcknowledgeableConditionState so it is always present.
|
|
condition.AckedState.ShouldNotBeNull();
|
|
// FINDING (1.5.378.106): Create auto-builds the FULL optional Part 9 child set from the
|
|
// embedded type definition WITHOUT us pre-setting any property — both ConfirmedState (Confirm
|
|
// sub-state machine) AND ShelvingState (Shelve state machine) come back non-null. This is
|
|
// RICHER than the SDK-notes' [SAMPLE-ONLY] caveat predicted (it suggested we'd have to
|
|
// instantiate optional children ourselves). Net: T15/T16 can drive SetConfirmedState /
|
|
// SetShelvingState directly — no manual child materialisation needed. Asserting both non-null
|
|
// so a future SDK bump that changes auto-build behaviour fails loudly.
|
|
condition.ConfirmedState.ShouldNotBeNull();
|
|
condition.ShelvingState.ShouldNotBeNull();
|
|
|
|
// WriteAlarmState now targets the real condition (not the bool[2] placeholder): no extra
|
|
// BaseDataVariable is minted for the alarm id.
|
|
sink.WriteAlarmState("alm-1", active: true, acknowledged: false, DateTime.UtcNow);
|
|
nm.VariableCount.ShouldBe(0); // fallback bool[2] path NOT taken
|
|
|
|
condition.ActiveState.Id.Value.ShouldBeTrue();
|
|
condition.AckedState.Id.Value.ShouldBeFalse();
|
|
condition.Retain.Value.ShouldBeTrue(); // active || !acked ⇒ retain
|
|
|
|
// Idempotent re-materialise (e.g. redeploy): still exactly one condition node for the id.
|
|
sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700);
|
|
nm.AlarmConditionCount.ShouldBe(1);
|
|
|
|
// RebuildAddressSpace clears the alarm dict too.
|
|
sink.RebuildAddressSpace();
|
|
nm.AlarmConditionCount.ShouldBe(0);
|
|
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
/// <summary>An unknown / limit-style AlarmType (with no script-supplied OPC limits) falls back to
|
|
/// the base <see cref="AlarmConditionState"/> per the T13 notes.</summary>
|
|
[Fact]
|
|
public async Task MaterialiseAlarmCondition_unknown_type_falls_back_to_base_condition()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
var nm = server.NodeManager!;
|
|
var sink = new SdkAddressSpaceSink(nm);
|
|
|
|
sink.EnsureFolder("eq-9", parentNodeId: null, displayName: "Equipment 9");
|
|
sink.MaterialiseAlarmCondition("alm-x", "eq-9", "GenericAlarm", "LimitAlarm", severity: 500);
|
|
|
|
var condition = nm.TryGetAlarmCondition("alm-x");
|
|
condition.ShouldNotBeNull();
|
|
// Base type exactly — NOT a LimitAlarmState (no limits to populate for a script alarm).
|
|
condition.GetType().ShouldBe(typeof(AlarmConditionState));
|
|
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
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<OpcUaApplicationHost>.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;
|
|
}
|
|
|
|
/// <summary>Cleans up the PKI root directory.</summary>
|
|
public void Dispose()
|
|
{
|
|
if (Directory.Exists(_pkiRoot))
|
|
{
|
|
try { Directory.Delete(_pkiRoot, recursive: true); }
|
|
catch { /* best-effort cleanup */ }
|
|
}
|
|
}
|
|
}
|