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; /// /// Integration tests for the F10b production binding: boot a real /// through , attach a , /// drive WriteValue/WriteAlarmCondition/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}"); /// Verifies that WriteValue creates and updates variables in the OPC UA node manager. [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(); } /// Verifies that WriteAlarmCondition (un-materialised fallback) creates a dedicated node /// distinct from value writes. [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(); } /// Verifies that RebuildAddressSpace clears all registered variables. [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(); } /// Verifies that NullOpcUaAddressSpaceSink does not crash on any call. [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; } /// T14 — materialises an equipment folder + a real Part 9 AlarmConditionState under it, /// then projects active state through WriteAlarmCondition. Asserts the node is a real /// , reachable under the equipment folder, and that /// ActiveState/Retain reflect the write. Also inspects which optional Part 9 children /// Create auto-builds (the T13 uncertainty) and records the finding inline. [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(); 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(); } /// An unknown / limit-style AlarmType (with no script-supplied OPC limits) falls back to /// the base per the T13 notes. [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(); } /// 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. [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(); } /// T15 null-guard — a base (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. [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(); } /// Builds a test with sensible defaults so each call /// site only specifies the fields it cares about. 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.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; } /// Cleans up the PKI root directory. public void Dispose() { if (Directory.Exists(_pkiRoot)) { try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort cleanup */ } } } }