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(); } /// T16 — each engine-driven on a /// materialised condition fires a real Part 9 condition event, stamping a FRESH per-event /// EventId (GUID bytes). Part 9 requires a unique EventId per event so inbound /// Acknowledge/Confirm (T17) can correlate back to the exact event being acked. /// /// We assert EventId-freshness (non-null + changed from its materialise-time value, and a second /// write yields a DIFFERENT EventId) plus no-throw. The node manager exposes no in-process hook to /// observe ReportEvent delivery without standing up a full client subscription + monitored /// item, so actual event DELIVERY to a subscribing client is proven live in T19 (Client.CLI) rather /// than faked here — see the comment below. /// [Fact] public async Task WriteAlarmCondition_fires_event_with_fresh_EventId_per_transition() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; var sink = new SdkAddressSpaceSink(nm); sink.EnsureFolder("eq-evt", parentNodeId: null, displayName: "Equipment Evt"); sink.MaterialiseAlarmCondition("alm-evt", "eq-evt", "HighTemp", "OffNormalAlarm", severity: 700); var condition = nm.TryGetAlarmCondition("alm-evt"); condition.ShouldNotBeNull(); // EventId at materialise time (Create stamps an initial one). Capture a COPY — the live byte[] // reference is reused, so we compare snapshots, not the same array instance. var materialiseEventId = (byte[]?)condition!.EventId.Value?.Clone(); // First engine-driven transition → fires an event with a fresh EventId. sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: false), DateTime.UtcNow); var firstEventId = (byte[]?)condition.EventId.Value?.Clone(); firstEventId.ShouldNotBeNull(); firstEventId!.Length.ShouldBe(16); // GUID bytes // Changed from the materialise-time value (a real event fired, not the create-time stamp). if (materialiseEventId is not null) { firstEventId.ShouldNotBe(materialiseEventId); } // Second transition → DIFFERENT EventId (fresh per event, so T17 ack-correlation is unambiguous). sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: true), DateTime.UtcNow); var secondEventId = (byte[]?)condition.EventId.Value?.Clone(); secondEventId.ShouldNotBeNull(); secondEventId!.ShouldNotBe(firstEventId); // NOTE: event DELIVERY (a subscribing client actually receiving these ActiveState/AckedState // transitions on the equipment folder) is an integration concern proven LIVE in T19 via the // Client.CLI alarm subscription — not asserted here, and deliberately NOT faked. This unit-level // test proves the firing path is exercised (no throw) and the Part 9 EventId-freshness invariant // that T17's ack routing depends on. await host.DisposeAsync(); } /// T16 — firing an event must not break the state projection even when ReportEvent could /// fail. We can't easily force ReportEvent to throw in-process, but we CAN prove the projection + /// firing path is exception-safe end-to-end across repeated transitions and that the condition's /// mandatory state stays correct after firing. [Fact] public async Task WriteAlarmCondition_firing_does_not_disturb_projected_state() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; var sink = new SdkAddressSpaceSink(nm); sink.EnsureFolder("eq-evt2", parentNodeId: null, displayName: "Equipment Evt2"); sink.MaterialiseAlarmCondition("alm-evt2", "eq-evt2", "HighTemp", "OffNormalAlarm", severity: 700); var condition = nm.TryGetAlarmCondition("alm-evt2"); condition.ShouldNotBeNull(); // Drive several transitions; each fires an event AND projects state. State must survive firing. Should.NotThrow(() => { sink.WriteAlarmCondition("alm-evt2", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow); sink.WriteAlarmCondition("alm-evt2", Snapshot(active: true, acknowledged: true, message: "acked"), DateTime.UtcNow); sink.WriteAlarmCondition("alm-evt2", Snapshot(active: false, acknowledged: true, message: "cleared"), DateTime.UtcNow); }); // Final projected state is intact after the last firing. condition!.ActiveState.Id.Value.ShouldBeFalse(); condition.AckedState.Id.Value.ShouldBeTrue(); condition.Message.Value.Text.ShouldBe("cleared"); condition.Retain.Value.ShouldBeFalse(); // inactive && acked ⇒ no retain 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 */ } } } }