Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs
T

316 lines
14 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>WriteAlarmCondition</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 WriteAlarmCondition (un-materialised fallback) creates a dedicated node
/// distinct from value writes.</summary>
[Fact]
public async Task WriteAlarmCondition_creates_dedicated_node_distinct_from_value_writes()
{
var (host, server) = await BootAsync();
var sink = new SdkAddressSpaceSink(server.NodeManager!);
sink.WriteAlarmCondition("alarm-7", Snapshot(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.WriteAlarmCondition("alarm-c", Snapshot(active: true), 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.WriteAlarmCondition("a", Snapshot(active: true), 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 WriteAlarmCondition. 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_WriteAlarmCondition_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 WriteAlarmCondition 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();
// WriteAlarmCondition now targets the real condition (not the bool[2] placeholder): no extra
// BaseDataVariable is minted for the alarm id.
sink.WriteAlarmCondition("alm-1", Snapshot(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();
}
/// <summary>T15 — the full condition state bridges through WriteAlarmCondition. Materialise a
/// condition, then push a rich snapshot (active + unacked + disabled + timed-shelved + high severity
/// + message) and assert every projected child reflects it:
/// ActiveState/AckedState/EnabledState/ConfirmedState/ShelvingState/Severity/Message/Retain.</summary>
[Fact]
public async Task WriteAlarmCondition_projects_full_state_onto_materialised_condition()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
var sink = new SdkAddressSpaceSink(nm);
sink.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2");
sink.MaterialiseAlarmCondition("alm-rich", "eq-2", "HighPressure", "OffNormalAlarm", severity: 300);
var condition = nm.TryGetAlarmCondition("alm-rich");
condition.ShouldNotBeNull();
// Rich snapshot: active, unacknowledged, unconfirmed, DISABLED, TIMED-shelved, severity 850, message.
sink.WriteAlarmCondition(
"alm-rich",
new AlarmConditionSnapshot(
Active: true,
Acknowledged: false,
Confirmed: false,
Enabled: false,
Shelving: AlarmShelvingKind.Timed,
Severity: 850,
Message: "Pressure above limit"),
DateTime.UtcNow);
condition.ActiveState.Id.Value.ShouldBeTrue();
condition.AckedState.Id.Value.ShouldBeFalse();
condition.EnabledState.Id.Value.ShouldBeFalse(); // disabled
condition.ConfirmedState!.Id.Value.ShouldBeFalse(); // unconfirmed
// SetShelvingState(shelved:true, oneShot:false) drives the shelving sub-state machine into a
// shelved state; CurrentState is populated (TimedShelved).
condition.ShelvingState!.CurrentState.Id.Value.ShouldNotBeNull();
// Severity 850 → High bucket (>= 800) → EventSeverity.High (the SDK stamps the ushort value).
condition.Severity.Value.ShouldBe((ushort)EventSeverity.High);
condition.Message.Value.Text.ShouldBe("Pressure above limit");
condition.Retain.Value.ShouldBeTrue(); // active || !acked ⇒ retain
await host.DisposeAsync();
}
/// <summary>T15 null-guard — a base <see cref="AlarmConditionState"/> (AlarmType "AlarmCondition")
/// whose optional ConfirmedState child the SDK might not materialise must not throw when a snapshot
/// sets Confirmed/Shelving. We force the ConfirmedState child to null after materialise to simulate a
/// leaner child set, then write a snapshot that sets Confirmed=true — the write must be a safe no-op
/// on that child rather than an NRE, while still projecting the mandatory state.</summary>
[Fact]
public async Task WriteAlarmCondition_null_optional_child_does_not_throw()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
var sink = new SdkAddressSpaceSink(nm);
sink.EnsureFolder("eq-3", parentNodeId: null, displayName: "Equipment 3");
sink.MaterialiseAlarmCondition("alm-base", "eq-3", "Generic", "AlarmCondition", severity: 200);
var condition = nm.TryGetAlarmCondition("alm-base");
condition.ShouldNotBeNull();
condition.GetType().ShouldBe(typeof(AlarmConditionState)); // base type
// Simulate a build where the optional Confirm sub-state machine was NOT created.
condition.ConfirmedState = null;
// Setting Confirmed on a condition with no ConfirmedState child must not throw.
Should.NotThrow(() =>
sink.WriteAlarmCondition(
"alm-base",
new AlarmConditionSnapshot(
Active: true,
Acknowledged: false,
Confirmed: true, // targets the (now-null) optional child
Enabled: true,
Shelving: AlarmShelvingKind.OneShot,
Severity: 500,
Message: "still works"),
DateTime.UtcNow));
// Mandatory state still projected despite the missing optional child.
condition.ActiveState.Id.Value.ShouldBeTrue();
condition.AckedState.Id.Value.ShouldBeFalse();
condition.Message.Value.Text.ShouldBe("still works");
await host.DisposeAsync();
}
/// <summary>Builds a test <see cref="AlarmConditionSnapshot"/> with sensible defaults so each call
/// site only specifies the fields it cares about.</summary>
private static AlarmConditionSnapshot Snapshot(
bool active = false,
bool acknowledged = true,
bool confirmed = true,
bool enabled = true,
AlarmShelvingKind shelving = AlarmShelvingKind.Unshelved,
ushort severity = 500,
string message = "test") =>
new(active, acknowledged, confirmed, enabled, shelving, severity, message);
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 */ }
}
}
}