using Opc.Ua; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; /// /// Gitea #641 — a full recreates every native condition /// inactive, and when the alarm-tag set is unchanged nothing tells the new node its state again (the driver /// does not re-subscribe, and Galaxy / OpcUaClient have nothing to replay anyway). The node manager therefore /// carries each NATIVE condition's live state across the rebuild: captured before the clear, restored when /// the same id is re-materialised native in the same apply, and reported as ONE condition event when the /// apply completes (). /// /// /// "One event" is observed here through the condition's EventId, which every report stamps fresh. /// The exact count over the wire is pinned by NativeAlarmRebuildCarryOverWireTests. /// public sealed class NodeManagerNativeAlarmRebuildCarryOverTests : IDisposable { private const string Device = "Plant/MTConnect/dev1"; private const string Alarm = "Plant/MTConnect/dev1/Xtravel"; private static CancellationToken Ct => TestContext.Current.CancellationToken; private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-641-{Guid.NewGuid():N}"); private static AlarmConditionSnapshot Fault(bool acked) => new( Active: true, Acknowledged: acked, Confirmed: true, Enabled: true, Shelving: AlarmShelvingKind.Unshelved, Severity: 800, Message: "Xtravel FAULT 1001", StatusCode: OpcUaStatus.Good); [Trait("Category", "Unit")] [Fact] public async Task An_active_unacked_native_condition_survives_a_rebuild_and_is_reported_once() { var (host, nm) = await BootAsync(); var raisedAt = new DateTime(2026, 10, 8, 9, 0, 0, DateTimeKind.Utc); Materialise(nm, native: true); nm.WriteAlarmCondition(Alarm, Fault(acked: false), raisedAt, AddressSpaceRealm.Raw); nm.RebuildAddressSpace(); Materialise(nm, native: true); var restored = Condition(nm); var idBeforeComplete = (byte[]?)restored.EventId.Value?.Clone(); // State is back as soon as the node exists — a Read or a ConditionRefresh in between already sees it. restored.ActiveState.Id.Value.ShouldBeTrue(); restored.AckedState.Id.Value.ShouldBeFalse(); restored.Retain.Value.ShouldBeTrue(); restored.Severity.Value.ShouldBe((ushort)800); restored.Message.Value.Text.ShouldBe("Xtravel FAULT 1001"); restored.Quality.Value.Code.ShouldBe(StatusCodes.Good); nm.CompleteAddressSpaceApply(); // The report happens at the END of the apply (after the notifier wiring), stamped with the instant the // state came about, not the deploy time. restored.EventId.Value.ShouldNotBe(idBeforeComplete); restored.Time.Value.ShouldBe(raisedAt); // ...and only once: a second completion with nothing pending reports nothing. var idAfterComplete = (byte[]?)restored.EventId.Value?.Clone(); nm.CompleteAddressSpaceApply(); restored.EventId.Value.ShouldBe(idAfterComplete); await host.DisposeAsync(); } /// /// The acknowledgement a CLIENT applied lives only on the node (the SDK applied it; no driver or projector /// saw it) — the reason the carry-over reads the node rather than any host-side cache. /// [Trait("Category", "Unit")] [Fact] public async Task A_client_acknowledgement_survives_the_rebuild() { var (host, nm) = await BootAsync(); Materialise(nm, native: true); nm.WriteAlarmCondition(Alarm, Fault(acked: false), DateTime.UtcNow, AddressSpaceRealm.Raw); // What the SDK's Acknowledge handler does to the node; nothing else hears of it. Condition(nm).SetAcknowledgedState(nm.SystemContext, true); nm.RebuildAddressSpace(); Materialise(nm, native: true); nm.CompleteAddressSpaceApply(); var restored = Condition(nm); restored.ActiveState.Id.Value.ShouldBeTrue(); restored.AckedState.Id.Value.ShouldBeTrue(); restored.Retain.Value.ShouldBeTrue(); await host.DisposeAsync(); } /// /// Gitea #641 review — the comment a client left with its acknowledgement is condition state too (native /// conditions support AddComment), and an event the condition reported AFTER the restore (a client method /// call landing on an SDK thread before the apply completes) must not have its newer Time rolled /// back by the deferred restore report. /// [Trait("Category", "Unit")] [Fact] public async Task The_comment_is_carried_and_a_newer_report_is_not_rolled_back() { var (host, nm) = await BootAsync(); var raisedAt = new DateTime(2026, 10, 8, 9, 0, 0, DateTimeKind.Utc); Materialise(nm, native: true); nm.WriteAlarmCondition(Alarm, Fault(acked: false), raisedAt, AddressSpaceRealm.Raw); Condition(nm).Comment.Value = new LocalizedText("on it"); nm.RebuildAddressSpace(); Materialise(nm, native: true); var later = raisedAt.AddMinutes(5); nm.WriteAlarmQuality(Alarm, StatusCodes.BadNoCommunication, later, AddressSpaceRealm.Raw); // reports at `later` nm.CompleteAddressSpaceApply(); var restored = Condition(nm); restored.Comment.Value.Text.ShouldBe("on it"); restored.Time.Value.ShouldBe(later, "the restore report must not stamp the condition back to the raise time"); await host.DisposeAsync(); } /// Falsification control: a condition in the no-event position comes back exactly as a fresh /// materialise builds it, and reports nothing. [Trait("Category", "Unit")] [Fact] public async Task A_normal_native_condition_reports_nothing_and_keeps_the_fresh_state() { var (host, nm) = await BootAsync(); Materialise(nm, native: true); nm.WriteAlarmCondition(Alarm, Fault(acked: false) with { Active = false, Acknowledged = true, Message = "cleared" }, DateTime.UtcNow, AddressSpaceRealm.Raw); nm.RebuildAddressSpace(); Materialise(nm, native: true); var fresh = Condition(nm); var idBefore = (byte[]?)fresh.EventId.Value?.Clone(); nm.CompleteAddressSpaceApply(); fresh.EventId.Value.ShouldBe(idBefore); fresh.ActiveState.Id.Value.ShouldBeFalse(); fresh.AckedState.Id.Value.ShouldBeTrue(); fresh.Retain.Value.ShouldBeFalse(); fresh.Message.Value.Text.ShouldBe("Xtravel", "a normal condition keeps the materialise's message"); fresh.Severity.Value.ShouldBe((ushort)700, "and its authored severity"); await host.DisposeAsync(); } /// /// The source-data quality is carried for a NORMAL condition too, silently: quality is annotated only on a /// driver's connectivity edge, so without the carry a rebuild left every normal native condition reading /// BadWaitingForInitialData until the driver next flapped. /// [Trait("Category", "Unit")] [Fact] public async Task A_normal_condition_keeps_its_quality_without_an_event() { var (host, nm) = await BootAsync(); Materialise(nm, native: true); nm.WriteAlarmQuality(Alarm, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); nm.RebuildAddressSpace(); Materialise(nm, native: true); var fresh = Condition(nm); var idBefore = (byte[]?)fresh.EventId.Value?.Clone(); nm.CompleteAddressSpaceApply(); fresh.Quality.Value.Code.ShouldBe(StatusCodes.Good); fresh.EventId.Value.ShouldBe(idBefore); await host.DisposeAsync(); } /// A condition the rebuild did NOT re-create has its captured state discarded when the apply /// completes, so a later deploy that adds the same RawPath back gets a fresh node, not a resurrection. [Trait("Category", "Unit")] [Fact] public async Task A_condition_removed_by_the_rebuild_is_not_resurrected_by_a_later_add() { var (host, nm) = await BootAsync(); Materialise(nm, native: true); nm.WriteAlarmCondition(Alarm, Fault(acked: false), DateTime.UtcNow, AddressSpaceRealm.Raw); nm.RebuildAddressSpace(); nm.CompleteAddressSpaceApply(); // the alarm was removed: nothing re-materialised it Materialise(nm, native: true); // a later, surgical re-add of the same RawPath nm.CompleteAddressSpaceApply(); var readded = Condition(nm); readded.ActiveState.Id.Value.ShouldBeFalse(); readded.AckedState.Id.Value.ShouldBeTrue(); await host.DisposeAsync(); } /// A native condition re-materialised as SCRIPTED under the same id is a different alarm: nothing /// is restored onto it (and the scripted engine's own #487 re-assert owns scripted state). [Trait("Category", "Unit")] [Fact] public async Task A_kind_swap_on_the_same_id_is_not_restored() { var (host, nm) = await BootAsync(); Materialise(nm, native: true); nm.WriteAlarmCondition(Alarm, Fault(acked: false), DateTime.UtcNow, AddressSpaceRealm.Raw); nm.RebuildAddressSpace(); Materialise(nm, native: false); nm.CompleteAddressSpaceApply(); Condition(nm).ActiveState.Id.Value.ShouldBeFalse(); await host.DisposeAsync(); } /// Scripted conditions are not captured — ScriptedAlarmHostActor.ReassertConditionNodes /// (#487) already re-projects them after a reload, and a second source would double-report. [Trait("Category", "Unit")] [Fact] public async Task A_scripted_condition_is_not_carried() { var (host, nm) = await BootAsync(); Materialise(nm, native: false); nm.WriteAlarmCondition(Alarm, Fault(acked: false), DateTime.UtcNow, AddressSpaceRealm.Raw); nm.RebuildAddressSpace(); Materialise(nm, native: false); nm.CompleteAddressSpaceApply(); Condition(nm).ActiveState.Id.Value.ShouldBeFalse(); await host.DisposeAsync(); } private static void Materialise(OtOpcUaNodeManager nm, bool native) { nm.EnsureFolder(Device, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw); nm.MaterialiseAlarmCondition(Alarm, Device, "Xtravel", "OffNormalAlarm", 700, AddressSpaceRealm.Raw, isNative: native); } private static Opc.Ua.AlarmConditionState Condition(OtOpcUaNodeManager nm) => nm.TryGetAlarmCondition(Alarm, AddressSpaceRealm.Raw).ShouldNotBeNull(); private async Task<(OpcUaApplicationHost Host, OtOpcUaNodeManager Nm)> BootAsync() { var applicationUri = $"urn:OtOpcUa.Gitea641Test:{Guid.NewGuid():N}"; var started = await OpcUaHostStart.StartAsync( port => new OpcUaApplicationHost( new OpcUaApplicationHostOptions { ApplicationName = "OtOpcUa.Gitea641Test", ApplicationUri = applicationUri, OpcUaPort = port, PublicHostname = "localhost", PkiStoreRoot = _pkiRoot, }, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance), () => new OtOpcUaSdkServer(), Ct); var host = started.Host; var server = started.Server; return (host, server.NodeManager!); } /// Cleans up the PKI root directory. public void Dispose() { if (Directory.Exists(_pkiRoot)) { try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort cleanup */ } } } }