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)] [InlineData("Acknowledge", true, true)] [InlineData("Confirm", true, true)] [InlineData("AddComment", true, true)] [InlineData("OneShotShelve", true, true)] [InlineData("TimedShelve", true, true)] [InlineData("Unshelve", 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", true, 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); } // A native condition's Confirmed and Shelving are reached only through the methods. ProjectAs("setup"); await RecordedEvents.WaitUntilAsync(() => events.Has(folderItem, "setup")); if (native && method == "Confirm") await CallAsync(session, condition, "Acknowledge", events.LatestEventId(folderItem, "setup"), ct); if (native && method == "Unshelve") await CallAsync(session, condition, "OneShotShelve", events.LatestEventId(folderItem, "setup"), ct); await rig.DrainProjectionsAsync(); 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, 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 refused call reports nothing: Disable on a native condition is BadNotSupported, so no /// transition happened and no condition event may claim one. /// [Fact] public async Task A_refused_method_call_reports_no_condition_event() { var ct = TestContext.Current.CancellationToken; var rig = await StartAsync("CondMethodRefused", encrypted: false, ct); try { rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All)); rig.MaterialiseNative(); var current = PreState("Disable", 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), "Disable", null, ct)); 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); 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(); } } // ───────────────────────────── helpers ───────────────────────────── /// The condition state a case starts from — one in which its method is accepted. private 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. /// private 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, VariantCollection e) { 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) { 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, [60_000.0]), "Unshelve" => (MethodIds.AlarmConditionType_ShelvingState_Unshelve, []), _ => throw new ArgumentOutOfRangeException(nameof(method), method, null), }; await session.CallAsync(condition, methodId, ct, args); } }