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(); } /// T20 — the inbound double-emit is suppressed by the delta-gate. We simulate the REAL /// inbound sequence: a client Acknowledge whose T18 gate returned Good causes the SDK to apply the /// acked state to the node and auto-fire its OWN event (E2) WITHOUT going through WriteAlarmCondition. /// We reproduce that by applying the acked state directly onto the live AlarmConditionState the way /// the SDK would. THEN the engine re-projects the same logical transition through WriteAlarmCondition /// with the matching snapshot — and because that snapshot equals the node's current state, the /// delta-gate must fire NO event (no E3). We prove "no event fired" by asserting the condition's /// EventId is UNCHANGED across the WriteAlarmCondition call (ReportConditionEvent always restamps a /// fresh GUID EventId when it fires — same probe the T16 event test uses). [Fact] public async Task WriteAlarmCondition_suppresses_event_when_snapshot_equals_node_current_state() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; var sink = new SdkAddressSpaceSink(nm); sink.EnsureFolder("eq-ack", parentNodeId: null, displayName: "Equipment Ack"); sink.MaterialiseAlarmCondition("alm-ack", "eq-ack", "HighTemp", "OffNormalAlarm", severity: 700); var condition = nm.TryGetAlarmCondition("alm-ack"); condition.ShouldNotBeNull(); // Drive the alarm active+unacked through the engine path (a genuine transition → fires). sink.WriteAlarmCondition("alm-ack", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow); condition!.ActiveState.Id.Value.ShouldBeTrue(); condition.AckedState.Id.Value.ShouldBeFalse(); // === Simulate the SDK-applied inbound Acknowledge (E2) === // After T18's gate returns Good, the SDK applies the acked state to the node and auto-fires its // own event — directly on the node, BYPASSING WriteAlarmCondition. Reproduce that node mutation. lock (nm.Lock) { condition.SetAcknowledgedState(nm.SystemContext, true); condition.Message.Value = new LocalizedText("active"); condition.ClearChangeMasks(nm.SystemContext, includeChildren: true); } condition.AckedState.Id.Value.ShouldBeTrue(); // Capture the EventId AFTER the SDK-applied ack but BEFORE the engine re-projection. var beforeReProject = (byte[]?)condition.EventId.Value?.Clone(); // === Engine re-projects the SAME acked transition through WriteAlarmCondition (would-be E3) === // Snapshot equals the node's current state (active, acked, message "active") ⇒ delta-gate sees // no change ⇒ NO event fires. sink.WriteAlarmCondition("alm-ack", Snapshot(active: true, acknowledged: true, message: "active"), DateTime.UtcNow); var afterReProject = (byte[]?)condition.EventId.Value?.Clone(); // EventId is UNCHANGED ⇒ ReportConditionEvent did NOT run ⇒ E3 suppressed. afterReProject.ShouldBe(beforeReProject); // The projection still applied (state is intact) — only the redundant event was suppressed. condition.ActiveState.Id.Value.ShouldBeTrue(); condition.AckedState.Id.Value.ShouldBeTrue(); await host.DisposeAsync(); } /// T20 — genuine engine-driven transitions still fire exactly one event, and a second /// IDENTICAL WriteAlarmCondition (no delta vs the node's now-current state) fires zero more. We use /// the EventId-changed-on-fire probe: a fire restamps a fresh GUID EventId, a suppress leaves it /// untouched. [Fact] public async Task WriteAlarmCondition_fires_on_delta_and_suppresses_identical_reprojection() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; var sink = new SdkAddressSpaceSink(nm); sink.EnsureFolder("eq-delta", parentNodeId: null, displayName: "Equipment Delta"); sink.MaterialiseAlarmCondition("alm-delta", "eq-delta", "HighTemp", "OffNormalAlarm", severity: 700); var condition = nm.TryGetAlarmCondition("alm-delta"); condition.ShouldNotBeNull(); var beforeFirst = (byte[]?)condition!.EventId.Value?.Clone(); // Genuine transition: snapshot (active, unacked) differs from the materialise state // (inactive, acked) ⇒ delta ⇒ fires exactly one event (EventId changes). sink.WriteAlarmCondition("alm-delta", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow); var afterFirst = (byte[]?)condition.EventId.Value?.Clone(); afterFirst.ShouldNotBeNull(); afterFirst!.ShouldNotBe(beforeFirst); // fired // Identical re-projection: snapshot now EQUALS the node's current state ⇒ no delta ⇒ 0 more // events (EventId unchanged from the first fire). sink.WriteAlarmCondition("alm-delta", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow); var afterSecond = (byte[]?)condition.EventId.Value?.Clone(); afterSecond.ShouldBe(afterFirst); // suppressed // A FURTHER genuine transition (clear) differs again ⇒ fires once more. sink.WriteAlarmCondition("alm-delta", Snapshot(active: false, acknowledged: true, message: "cleared"), DateTime.UtcNow); var afterThird = (byte[]?)condition.EventId.Value?.Clone(); afterThird.ShouldNotBe(afterSecond); // fired await host.DisposeAsync(); } /// T20 — direct unit test of the pure fire-vs-suppress decision seam /// (). Equal states ⇒ suppress; any single /// field differing ⇒ fire. This pins the gate logic independent of the booted server. [Fact] public void ShouldFireConditionEvent_fires_only_on_a_field_delta() { var baseState = new OtOpcUaNodeManager.AlarmConditionDelta( Active: true, Acknowledged: false, Confirmed: true, Enabled: true, Shelving: AlarmShelvingKind.Unshelved, MappedSeverity: 100, Message: "m"); // Equal ⇒ suppress (this is the inbound double-emit case in pure form). OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState).ShouldBeFalse(); OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { }).ShouldBeFalse(); // Each single-field difference ⇒ fire. OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Active = false }).ShouldBeTrue(); OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Acknowledged = true }).ShouldBeTrue(); OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Confirmed = false }).ShouldBeTrue(); OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Enabled = false }).ShouldBeTrue(); OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Shelving = AlarmShelvingKind.Timed }).ShouldBeTrue(); OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { MappedSeverity = 900 }).ShouldBeTrue(); OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Message = "other" }).ShouldBeTrue(); } /// T20 — null-vs-empty Message normalization. Both snapshot.Message = null and a live node /// whose Message.Value.Text = null normalize to in /// , so a snapshot whose Message is null does NOT /// produce a phantom delta against a freshly-materialised node (whose Message is the displayName but /// whose Text reads back as the displayName string, and a snapshot that explicitly passes an empty /// string equally produces no delta vs a null-Message node). This pins the normalization so a /// future change to the null-coalescing in ReadConditionDelta / ToConditionDelta is caught here. [Fact] public void ShouldFireConditionEvent_null_and_empty_message_normalize_identically() { // Both null and "" collapse to string.Empty in AlarmConditionDelta — they are the same delta value. var withNull = new OtOpcUaNodeManager.AlarmConditionDelta( Active: false, Acknowledged: true, Confirmed: true, Enabled: true, Shelving: AlarmShelvingKind.Unshelved, MappedSeverity: 100, Message: string.Empty); var withEmpty = withNull with { Message = string.Empty }; // null-message snapshot (normalised to "") vs empty-message node (normalised to "") ⇒ no delta. OtOpcUaNodeManager.ShouldFireConditionEvent(withNull, withEmpty).ShouldBeFalse(); OtOpcUaNodeManager.ShouldFireConditionEvent(withEmpty, withNull).ShouldBeFalse(); // A non-empty message IS a delta vs the empty baseline. OtOpcUaNodeManager.ShouldFireConditionEvent(withNull, withNull with { Message = "pressure high" }).ShouldBeTrue(); } /// 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 */ } } } }