using Opc.Ua; using Opc.Ua.Client; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; /// /// Gitea #587 over the wire — every accepted Part 9 method call (Acknowledge, Confirm, AddComment, /// OneShotShelve, TimedShelve, Unshelve, Enable, Disable) on a native or scripted condition reaches a /// folder event item and a Server-object (i=2253) item as exactly one condition event /// carrying the new state. /// /// /// /// Before #587 a client Acknowledge produced no condition event at all: the SDK's own report is gated /// on AutoReportStateChanges (never set here), and the engine's re-projection found the node /// already acknowledged and suppressed its own. The code comments claiming "the SDK auto-fires its own /// event" were wrong — and a fix that only enabled the SDK's report would still double-report a shelve /// (the SDK reports before any shelving state is applied, then the re-projection reports the change). /// /// /// Each case runs the engine's (or, for a native alarm, the driver's) answer to the routed command as /// a re-projection from a pool thread, so "exactly one" covers the duplicate the re-projection could /// add, not only the method path. The count is read after a later sentinel transition arrives on /// the same item, so a late duplicate would already be in the queue ahead of it. /// /// public sealed class ConditionMethodEventTests { private const NodePermissions All = NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe | NodePermissions.AlarmRead | NodePermissions.AlarmAcknowledge | NodePermissions.AlarmConfirm | NodePermissions.AlarmShelve | NodePermissions.MethodCall; /// /// One accepted method call ⇒ one condition event, to the folder item and to the Server-object item, /// with the new state — while a session holding no grant receives none of it through the Server object. /// [Theory] [InlineData("Acknowledge", false, true)] [InlineData("Confirm", false, true)] [InlineData("AddComment", false, true)] [InlineData("OneShotShelve", false, true)] [InlineData("TimedShelve", false, true)] [InlineData("Unshelve", false, true)] [InlineData("Disable", false, true)] [InlineData("Enable", false, true)] // Native conditions support Acknowledge and AddComment only (the rest: A_refused_native_method_…). [InlineData("Acknowledge", true, true)] [InlineData("AddComment", true, true)] // Flag off: the server-wide AlarmAck role admits the call; delivery is ungated. [InlineData("Acknowledge", false, false)] [InlineData("Acknowledge", true, false)] [InlineData("OneShotShelve", false, false)] public async Task An_accepted_method_call_reports_exactly_one_condition_event(string method, bool native, bool aclOn) { var ct = TestContext.Current.CancellationToken; var rig = await StartAsync($"CondMethod{method}{native}{aclOn}", encrypted: false, ct); try { rig.Enforce(aclOn, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All)); rig.MaterialiseNative(); rig.MaterialiseScripted(); var current = PreState(method, native); WireSimulatedOwners(rig, native, () => current, s => current = s); using var session = await rig.ConnectAsync(anonymous: false, ct); var events = new RecordedEvents(); var subscription = await RecordedEvents.SubscribeAsync(session, events, ct); var folderItem = RecordedEvents.AddEventItem(subscription, UnsNodeId(session, EqA)); var serverItem = RecordedEvents.AddEventItem(subscription, ObjectIds.Server); await subscription.ApplyChangesAsync(ct); folderItem.Status.Error.ShouldBeNull(); using var ungranted = await rig.ConnectAsync(anonymous: true, ct); var ungrantedEvents = new RecordedEvents(); var ungrantedSubscription = await RecordedEvents.SubscribeAsync(ungranted, ungrantedEvents, ct); var ungrantedItem = RecordedEvents.AddEventItem(ungrantedSubscription, ObjectIds.Server); await ungrantedSubscription.ApplyChangesAsync(ct); var condition = ConditionNodeId(session, native); // Every transition the test drives also moves the simulated owner's state, so its re-projections // carry the node's current message. void ProjectAs(string message) { current = current with { Message = message }; rig.Project(native, current); } ProjectAs("base"); await RecordedEvents.WaitUntilAsync(() => events.Has(folderItem, "base") && events.Has(serverItem, "base")); await CallAsync(session, condition, method, events.LatestEventId(folderItem, "base"), ct); await rig.DrainProjectionsAsync(); ProjectAs("sentinel"); await RecordedEvents.WaitUntilAsync(() => events.Has(folderItem, "sentinel") && events.Has(serverItem, "sentinel") && (aclOn || ungrantedEvents.Has(ungrantedItem, "sentinel"))); foreach (var item in new[] { folderItem, serverItem }) { var baseEvents = events.ConditionEvents(item, "base"); baseEvents.Count.ShouldBe(2, $"the base transition plus exactly one {method} event ({item.StartNodeId})"); AssertNewState(method, native, baseEvents[1]); } if (aclOn) { ungrantedEvents.Of(ungrantedItem).ShouldBeEmpty( "a session with no grant receives no condition event through the Server object"); } else { ungrantedEvents.ConditionEvents(ungrantedItem, "base").Count.ShouldBe(2, "flag off: ungated, still once"); } await ungrantedSubscription.DeleteAsync(true, ct); await subscription.DeleteAsync(true, ct); } finally { await rig.DisposeAsync(); } } /// /// A native condition supports Acknowledge and AddComment only: Confirm, the shelve methods and /// Disable are refused, report nothing, and leave the condition Confirmed and Unshelved. /// /// /// Native Confirm/Shelve used to be accepted and applied locally — per node, never upstream, never to the /// redundant peer, and undone by the driver projection (a one-shot shelve silently, a timed one never /// expiring). Unshelve on an unshelved condition is refused by the SDK itself before our veto runs. /// [Theory] [InlineData("Confirm")] [InlineData("OneShotShelve")] [InlineData("TimedShelve")] [InlineData("Unshelve")] [InlineData("Disable")] public async Task A_refused_native_method_reports_no_condition_event(string method) { var ct = TestContext.Current.CancellationToken; var rig = await StartAsync($"CondMethodRefused{method}", encrypted: false, ct); try { rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All)); rig.MaterialiseNative(); var current = PreState(method, native: true); using var session = await rig.ConnectAsync(anonymous: false, ct); var events = new RecordedEvents(); var subscription = await RecordedEvents.SubscribeAsync(session, events, ct); var folderItem = RecordedEvents.AddEventItem(subscription, UnsNodeId(session, EqA)); var serverItem = RecordedEvents.AddEventItem(subscription, ObjectIds.Server); await subscription.ApplyChangesAsync(ct); rig.Project(native: true, current = current with { Message = "base" }); await RecordedEvents.WaitUntilAsync(() => events.Has(serverItem, "base")); var refused = await Should.ThrowAsync( CallAsync(session, ConditionNodeId(session, native: true), method, events.LatestEventId(serverItem, "base"), ct)); if (method != "Unshelve") refused.StatusCode.ShouldBe(StatusCodes.BadNotSupported); rig.Project(native: true, current with { Message = "sentinel" }); await RecordedEvents.WaitUntilAsync(() => events.Has(folderItem, "sentinel") && events.Has(serverItem, "sentinel")); events.ConditionEvents(folderItem, "base").Count.ShouldBe(1); events.ConditionEvents(serverItem, "base").Count.ShouldBe(1); var sentinel = events.ConditionEvents(serverItem, "sentinel")[0]; sentinel[RecordedEvents.ConfirmedField].Value.ShouldBe(true); sentinel[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved); await subscription.DeleteAsync(true, ct); } finally { await rig.DisposeAsync(); } } /// /// Materialising N conditions while a Server-object item is live costs N monitored-bookkeeping visits, /// not N²/2 — and every one of them still ends up marked monitored (#586 review, performance note). /// [Fact] public async Task Materialising_conditions_under_a_live_server_object_item_visits_each_once() { const int n = 40; var ct = TestContext.Current.CancellationToken; var rig = await StartAsync("CondMonitoredCost", encrypted: false, ct); try { rig.EnsureEquipment(); using var session = await rig.ConnectAsync(anonymous: true, ct); var events = new RecordedEvents(); var subscription = await RecordedEvents.SubscribeAsync(session, events, ct); RecordedEvents.AddEventItem(subscription, ObjectIds.Server); await subscription.ApplyChangesAsync(ct); var before = rig.Nm.AllEventsMonitoringConditionVisits; for (var i = 0; i < n; i++) rig.Sink.MaterialiseAlarmCondition($"bulk-{i}", EqA, $"bulk {i}", "AlarmCondition", 500, AddressSpaceRealm.Uns); (rig.Nm.AllEventsMonitoringConditionVisits - before).ShouldBe(n); for (var i = 0; i < n; i++) rig.Nm.ConditionEventsMonitored($"bulk-{i}", AddressSpaceRealm.Uns).ShouldBeTrue(); await subscription.DeleteAsync(true, ct); for (var i = 0; i < n; i++) rig.Nm.ConditionEventsMonitored($"bulk-{i}", AddressSpaceRealm.Uns).ShouldBeFalse( "the last item's delete still gives every unit back through the full reconcile"); } finally { await rig.DisposeAsync(); } } /// /// An OPC UA Acknowledge on a native condition reports exactly one event with ConfirmedState still /// true, and the condition stays Confirmed through the driver's echo, a clear and a re-activation. /// /// /// The SDK's Acknowledge sets Confirmed false on any condition that supports Confirm, and nothing resets /// it for a native alarm — ScadaBridge maps ConfirmedState into its alarm state, so every native /// alarm would read Unconfirmed after its first HMI ack (review of 167a48bf). /// [Fact] public async Task A_native_acknowledge_keeps_the_condition_confirmed_through_clear_and_reactivation() { var ct = TestContext.Current.CancellationToken; var rig = await StartAsync("CondNativeAckConfirmed", encrypted: false, ct); try { rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All)); rig.MaterialiseNative(); rig.Nm.NativeAlarmAckRouter = _ => { }; using var session = await rig.ConnectAsync(anonymous: false, ct); var events = new RecordedEvents(); var subscription = await RecordedEvents.SubscribeAsync(session, events, ct); var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server); await subscription.ApplyChangesAsync(ct); rig.Project(true, NativeState(active: true, acked: false, "base")); await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base")); await CallAsync(session, ConditionNodeId(session, true), "Acknowledge", events.LatestEventId(item, "base"), ct); rig.Project(true, NativeState(active: true, acked: true, "base")); // the driver's echo of the ack rig.Project(true, NativeState(active: false, acked: true, "cleared")); rig.Project(true, NativeState(active: true, acked: false, "reactivated")); await RecordedEvents.WaitUntilAsync(() => events.Has(item, "reactivated")); var baseEvents = events.ConditionEvents(item, "base"); baseEvents.Count.ShouldBe(2, "the base transition plus the one ack event; the echo is no delta"); baseEvents[1][RecordedEvents.AckedField].Value.ShouldBe(true); foreach (var e in baseEvents.Concat(events.ConditionEvents(item, "cleared")).Concat(events.ConditionEvents(item, "reactivated"))) e[RecordedEvents.ConfirmedField].Value.ShouldBe(true); await subscription.DeleteAsync(true, ct); } finally { await rig.DisposeAsync(); } } /// /// A scripted TimedShelve expires: the engine's expiry comes back as an Unshelved re-projection, which /// reports exactly one unshelve event after the one shelve event. /// /// /// The method path applies the shelve with shelvingTime 0, as the projection does, so the expiry is /// the engine's (computed from the command's UnshelveAtUtc), never the SDK timer's — a node timer /// would only route an Unshelve back to the engine anyway. The stand-in engine here schedules its expiry /// from the real UnshelveAtUtc the method handler computed. /// [Fact] public async Task A_scripted_timed_shelve_expires_with_exactly_one_unshelve_event() { var ct = TestContext.Current.CancellationToken; var rig = await StartAsync("CondTimedExpiry", encrypted: false, ct); try { rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All)); rig.MaterialiseScripted(); var current = PreState("TimedShelve", native: false) with { Message = "base" }; Task? expiry = null; rig.Nm.AlarmCommandRouter = cmd => { if (cmd.Operation != "TimedShelve") return; current = current with { Shelving = AlarmShelvingKind.Timed }; rig.ProjectLater(native: false, current); var unshelveAt = cmd.UnshelveAtUtc!.Value; expiry = Task.Run(async () => { var wait = unshelveAt - DateTime.UtcNow; if (wait > TimeSpan.Zero) await Task.Delay(wait); current = current with { Shelving = AlarmShelvingKind.Unshelved }; rig.Project(native: false, current); }); }; using var session = await rig.ConnectAsync(anonymous: false, ct); var events = new RecordedEvents(); var subscription = await RecordedEvents.SubscribeAsync(session, events, ct); var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server); await subscription.ApplyChangesAsync(ct); rig.Project(false, current); await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base")); await CallAsync(session, ConditionNodeId(session, false), "TimedShelve", null, ct, shelvingTimeMs: 1000); await rig.DrainProjectionsAsync(); (expiry is not null).ShouldBeTrue("the TimedShelve reached the engine"); await expiry!; rig.Project(false, current = current with { Message = "sentinel" }); await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel")); var shelving = events.ConditionEvents(item, "base").Select(e => e[RecordedEvents.ShelvingField].Value).ToList(); shelving.ShouldBe( [ObjectIds.ShelvedStateMachineType_Unshelved, ObjectIds.ShelvedStateMachineType_TimedShelved, ObjectIds.ShelvedStateMachineType_Unshelved], "base, then exactly one shelve event, then exactly one expiry event"); await subscription.DeleteAsync(true, ct); } finally { await rig.DisposeAsync(); } } /// /// Acknowledging an acknowledged condition reports nothing, and does not flip a scripted condition's /// Confirmed out of step with its engine. /// /// /// The SDK accepts a re-ack and sets Confirmed false again; the engine ignores it. The scripted leg /// acknowledges and confirms first, so the flip would be visible on the sentinel. /// [Theory] [InlineData(false)] [InlineData(true)] public async Task A_re_acknowledge_reports_nothing_and_changes_nothing(bool native) { var ct = TestContext.Current.CancellationToken; var rig = await StartAsync($"CondReAck{native}", encrypted: false, ct); try { rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All)); rig.MaterialiseNative(); rig.MaterialiseScripted(); var current = PreState("Acknowledge", native) with { Message = "base" }; WireSimulatedOwners(rig, native, () => current, s => current = s); using var session = await rig.ConnectAsync(anonymous: false, ct); var events = new RecordedEvents(); var subscription = await RecordedEvents.SubscribeAsync(session, events, ct); var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server); await subscription.ApplyChangesAsync(ct); var condition = ConditionNodeId(session, native); rig.Project(native, current); await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base")); await CallAsync(session, condition, "Acknowledge", events.LatestEventId(item, "base"), ct); await rig.DrainProjectionsAsync(); var expected = 2; if (!native) { await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == 2); await CallAsync(session, condition, "Confirm", events.LatestEventId(item, "base"), ct); await rig.DrainProjectionsAsync(); expected = 3; } await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == expected); await CallAsync(session, condition, "Acknowledge", events.LatestEventId(item, "base"), ct); await rig.DrainProjectionsAsync(); rig.Project(native, current = current with { Message = "sentinel" }); await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel")); events.ConditionEvents(item, "base").Count.ShouldBe(expected, "the re-ack must report nothing"); var sentinel = events.ConditionEvents(item, "sentinel"); sentinel.Count.ShouldBe(1); sentinel[0][RecordedEvents.AckedField].Value.ShouldBe(true); sentinel[0][RecordedEvents.ConfirmedField].Value.ShouldBe(true, "the re-ack must not unconfirm the node"); await subscription.DeleteAsync(true, ct); } finally { await rig.DisposeAsync(); } } // ───────────────────────────── helpers ───────────────────────────── private static AlarmConditionSnapshot NativeState(bool active, bool acked, string message) => new( Active: active, Acknowledged: acked, Confirmed: true, Enabled: true, Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: message); /// The condition state a case starts from — one in which its method is accepted. internal static AlarmConditionSnapshot PreState(string method, bool native) { var s = new AlarmConditionSnapshot( Active: true, Acknowledged: false, Confirmed: native, Enabled: true, Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: "pre"); if (native) return s; // the driver projection's constants: Confirmed, Unshelved return method switch { "Confirm" => s with { Acknowledged = true, Confirmed = false }, "Unshelve" => s with { Shelving = AlarmShelvingKind.OneShot }, "Enable" => s with { Enabled = false }, _ => s, }; } /// /// Stands in for the scripted engine and the native driver: each routed command comes back as a /// re-projection of the state it produces, from a pool thread. /// internal static void WireSimulatedOwners( ServerEventRig rig, bool native, Func current, Action set) { rig.Nm.NativeAlarmAckRouter = _ => { // NativeAlarmProjector's answer to the driver's ack echo: Acked, with its Confirmed/Unshelved constants. var next = current() with { Acknowledged = true, Confirmed = true, Shelving = AlarmShelvingKind.Unshelved }; set(next); rig.ProjectLater(native: true, next); }; rig.Nm.AlarmCommandRouter = cmd => { if (native || cmd.AlarmId != ScriptedAlarmId) return; // the engine owns only scripted alarms var s = current(); var next = cmd.Operation switch { "Acknowledge" => s with { Acknowledged = true }, "Confirm" => s with { Confirmed = true }, "OneShotShelve" => s with { Shelving = AlarmShelvingKind.OneShot }, "TimedShelve" => s with { Shelving = AlarmShelvingKind.Timed }, "Unshelve" => s with { Shelving = AlarmShelvingKind.Unshelved }, "Enable" => s with { Enabled = true }, "Disable" => s with { Enabled = false }, _ => s, // AddComment: no field of the snapshot changes }; set(next); rig.ProjectLater(native: false, next); }; } private static void AssertNewState(string method, bool native, VariantCollection e) { // A native condition is never Unconfirmed, whatever the method (the SDK's ack would make it so). if (native) e[RecordedEvents.ConfirmedField].Value.ShouldBe(true); switch (method) { case "Acknowledge": e[RecordedEvents.AckedField].Value.ShouldBe(true); break; case "Confirm": e[RecordedEvents.ConfirmedField].Value.ShouldBe(true); break; case "AddComment": (e[RecordedEvents.CommentField].Value as LocalizedText)?.Text.ShouldBe("note"); break; case "OneShotShelve": e[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_OneShotShelved); break; case "TimedShelve": e[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_TimedShelved); break; case "Unshelve": e[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved); break; case "Disable": e[RecordedEvents.EnabledField].Value.ShouldBe(false); break; case "Enable": e[RecordedEvents.EnabledField].Value.ShouldBe(true); break; default: throw new ArgumentOutOfRangeException(nameof(method), method, null); } } /// /// Calls one Part 9 method on the condition by its type's method id (the SDK resolves it against the /// condition's method declarations — the shelve methods included); throws on a bad result. /// private static async Task CallAsync( ISession session, NodeId condition, string method, byte[]? eventId, CancellationToken ct, double shelvingTimeMs = 60_000) { var (methodId, args) = method switch { "Acknowledge" => (MethodIds.AcknowledgeableConditionType_Acknowledge, new object[] { eventId!, new LocalizedText("ack") }), "Confirm" => (MethodIds.AcknowledgeableConditionType_Confirm, [eventId!, new LocalizedText("confirm")]), "AddComment" => (MethodIds.ConditionType_AddComment, [eventId!, new LocalizedText("note")]), "Enable" => (MethodIds.ConditionType_Enable, []), "Disable" => (MethodIds.ConditionType_Disable, []), "OneShotShelve" => (MethodIds.AlarmConditionType_ShelvingState_OneShotShelve, []), "TimedShelve" => (MethodIds.AlarmConditionType_ShelvingState_TimedShelve, [shelvingTimeMs]), "Unshelve" => (MethodIds.AlarmConditionType_ShelvingState_Unshelve, []), _ => throw new ArgumentOutOfRangeException(nameof(method), method, null), }; await session.CallAsync(condition, methodId, ct, args); } }