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 — and the SDK's own expiry timer routes /// nothing (review R1 M2). /// /// /// The expiry is the engine's (computed from the command's UnshelveAtUtc). Since Gitea #597 the node /// is shelved for the real duration, so the SDK's own timer fires at that instant. It used to route an /// operator-style Unshelve by system, which — because the real engine only checks expiry every /// 5 s — pre-empted the engine's AutoUnshelve on every expiry. This stand-in engine therefore /// (a) expires LATE, like the real one, so the SDK timer demonstrably fires first; and (b) ACTS on a routed /// Unshelve as the real engine would, recording who unshelved it. The wrapped /// OnTimedUnshelve is the positive control: the SDK timer did fire, and still nothing was routed and /// the unshelve on record is the engine's own. /// [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; var routed = new System.Collections.Concurrent.ConcurrentQueue(); string? unshelvedBy = null; var engineLag = TimeSpan.FromMilliseconds(1500); // the real engine's 5 s shelving-check granularity rig.Nm.AlarmCommandRouter = cmd => { routed.Enqueue(cmd.Operation); if (cmd.Operation == "Unshelve") { // What the real engine does with a routed Unshelve: an operator-style unshelve. unshelvedBy ??= $"Unshelve:{cmd.User}"; current = current with { Shelving = AlarmShelvingKind.Unshelved, UnshelveAtUtc = null }; rig.ProjectLater(native: false, current); return; } if (cmd.Operation != "TimedShelve") return; // The real engine's projection carries its expiry, so the re-projection keeps the SDK timer armed // for the real remaining time (without it, it would re-arm for MaxTimeShelved and never fire here). current = current with { Shelving = AlarmShelvingKind.Timed, UnshelveAtUtc = cmd.UnshelveAtUtc }; rig.ProjectLater(native: false, current); var unshelveAt = cmd.UnshelveAtUtc!.Value; expiry = Task.Run(async () => { var wait = unshelveAt + engineLag - DateTime.UtcNow; if (wait > TimeSpan.Zero) await Task.Delay(wait); if (current.Shelving != AlarmShelvingKind.Timed) return; // ApplyShelvingCheck: Timed only unshelvedBy ??= "AutoUnshelve:system"; current = current with { Shelving = AlarmShelvingKind.Unshelved, UnshelveAtUtc = null }; rig.Project(native: false, current); }); }; // Count the SDK's own expiry-timer firings without changing what the production handler does. var condition = rig.Nm.TryGetAlarmCondition(ScriptedAlarmId)!; var sdkTimerFired = 0; var production = condition.OnTimedUnshelve!; condition.OnTimedUnshelve = (context, c) => { Interlocked.Increment(ref sdkTimerFired); return production(context, c); }; 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")); Volatile.Read(ref sdkTimerFired).ShouldBeGreaterThanOrEqualTo(1, "the SDK's timed-unshelve timer fired"); routed.ShouldNotContain("Unshelve", "the SDK timer must not route an operator-style Unshelve"); unshelvedBy.ShouldBe("AutoUnshelve:system", "the engine's own expiry owns the unshelve"); 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(); } } /// /// A scripted condition's events carry the real remaining shelve time in /// ShelvingState/UnshelveTime — after a client TimedShelve, after the engine's re-projection of it, /// and after an engine-driven timed shelve (the AdminUI /alerts path, which never calls the method). /// /// /// Gitea #597: both paths applied the shelve with shelvingTime 0, which the SDK reads as "the /// longest shelve allowed" — MaxTimeShelved, int.MaxValue ms — so every timed shelve /// advertised ~24.8 days remaining while the engine expired it on time. Read through the event select /// clause, which is what an alarm client consumes. (A direct Read of the child also works since Gitea /// #629 gave condition children instance-scoped NodeIds — see ConditionChildNodeIdTests.) /// [Fact] public async Task A_scripted_timed_shelve_reports_the_real_remaining_unshelve_time() { var ct = TestContext.Current.CancellationToken; var rig = await StartAsync("CondUnshelveTime", encrypted: false, ct); try { rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All)); rig.MaterialiseScripted(); var current = PreState("TimedShelve", native: false) with { Message = "base" }; rig.Nm.AlarmCommandRouter = cmd => { if (cmd.Operation != "TimedShelve") return; // The engine's answer carries its own expiry — what ScriptedAlarmHostActor projects. current = current with { Shelving = AlarmShelvingKind.Timed, UnshelveAtUtc = cmd.UnshelveAtUtc }; rig.ProjectLater(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); double RemainingMs(string message) => (double)events.ConditionEvents(item, message)[^1][RecordedEvents.UnshelveTimeField].Value; rig.Project(false, current); await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base")); RemainingMs("base").ShouldBe(0d, "unshelved"); // The method's one event carries the shelve's remaining time … await CallAsync(session, ConditionNodeId(session, false), "TimedShelve", null, ct, shelvingTimeMs: 60_000); await rig.DrainProjectionsAsync(); await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == 2); RemainingMs("base").ShouldBeInRange(50_000, 60_000, "the TimedShelve method event"); // … and so does a later transition, after the engine's re-projection re-applied the shelve. rig.Project(false, current = current with { Message = "after-reprojection" }); await RecordedEvents.WaitUntilAsync(() => events.Has(item, "after-reprojection")); RemainingMs("after-reprojection").ShouldBeInRange(50_000, 60_000, "after the engine's re-projection"); // Engine-driven (the AdminUI path): unshelve, then a timed shelve that reaches the node only as a // projection, never through the method. rig.Project(false, current = current with { Shelving = AlarmShelvingKind.Unshelved, UnshelveAtUtc = null, Message = "unshelved" }); await RecordedEvents.WaitUntilAsync(() => events.Has(item, "unshelved")); RemainingMs("unshelved").ShouldBe(0d); rig.Project(false, current with { Shelving = AlarmShelvingKind.Timed, UnshelveAtUtc = DateTime.UtcNow.AddSeconds(30), Message = "projected", }); await RecordedEvents.WaitUntilAsync(() => events.Has(item, "projected")); RemainingMs("projected").ShouldBeInRange(20_000, 30_000, "a projected timed shelve"); await subscription.DeleteAsync(true, ct); } finally { await rig.DisposeAsync(); } } /// /// On a node that is not the redundancy Primary, an OPC UA Acknowledge of a NATIVE condition is refused /// with BadInvalidState: nothing is routed to the driver, nothing is applied, nothing is reported. /// On the Primary the same call is accepted (the positive control). /// /// /// Gitea #597 decision. The ack used to be applied locally and reported, while DriverHostActor's /// Primary gate dropped the route upstream — so the driver never acknowledged, its next transition /// projected Acked = false, and a client acking against the Secondary saw acked → unacked. An /// honest refusal replaces an ack that visibly sticks and then reverts. Scripted conditions are /// unaffected: their engine runs on both nodes, so the Secondary's ack is real. /// [Fact] public async Task A_native_acknowledge_on_a_non_primary_node_is_refused() { var ct = TestContext.Current.CancellationToken; var rig = await StartAsync("CondNativeAckSecondary", encrypted: false, ct); try { rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All)); rig.MaterialiseNative(); var current = PreState("Acknowledge", native: true) with { Message = "base" }; WireSimulatedOwners(rig, native: true, () => current, s => current = s); var routed = 0; var driverAck = rig.Nm.NativeAlarmAckRouter!; rig.Nm.NativeAlarmAckRouter = ack => { Interlocked.Increment(ref routed); driverAck(ack); }; var primary = false; rig.Nm.NativeAlarmAckPrimaryGate = () => primary; 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(native: true, current); await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base")); var condition = ConditionNodeId(session, native: true); var refused = await Should.ThrowAsync( CallAsync(session, condition, "Acknowledge", events.LatestEventId(item, "base"), ct)); refused.StatusCode.ShouldBe(StatusCodes.BadInvalidState); routed.ShouldBe(0, "a refused ack reaches no driver"); rig.Nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw)!.AckedState.Id.Value .ShouldBeFalse("a refused ack applies nothing"); // Positive control: the same call on the Primary is accepted, routed and reported. primary = true; await CallAsync(session, condition, "Acknowledge", events.LatestEventId(item, "base"), ct); await rig.DrainProjectionsAsync(); routed.ShouldBe(1); rig.Project(native: true, current = current with { Message = "sentinel" }); await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel")); var baseEvents = events.ConditionEvents(item, "base"); baseEvents.Count.ShouldBe(2, "the base transition plus the Primary's one ack event; the refusal reported nothing"); baseEvents[1][RecordedEvents.AckedField].Value.ShouldBe(true); await subscription.DeleteAsync(true, ct); } finally { await rig.DisposeAsync(); } } /// /// Acknowledging an acknowledged condition WITHOUT a comment reports nothing, and does not flip a scripted /// condition's Confirmed out of step with its engine. With a comment it reports exactly one event carrying /// that comment — the comment is a Part 9 change in its own right, as AddComment is (Gitea #597) — and /// still leaves Acked/Confirmed alone. /// /// /// The SDK accepts a re-ack, writes its comment, 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. Before /// #597 a commented re-ack wrote the comment to the node but reported nothing, so no subscriber ever saw /// it. /// [Theory] [InlineData(false, null)] [InlineData(true, null)] [InlineData(false, "again")] [InlineData(true, "again")] public async Task A_re_acknowledge_reports_only_its_comment_and_changes_nothing_else(bool native, string? reAckComment) { 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); var routedAcks = 0; var driverAck = rig.Nm.NativeAlarmAckRouter!; rig.Nm.NativeAlarmAckRouter = ack => { Interlocked.Increment(ref routedAcks); driverAck(ack); }; 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); var routedBefore = routedAcks; await CallAsync(session, condition, "Acknowledge", events.LatestEventId(item, "base"), ct, ackComment: reAckComment); await rig.DrainProjectionsAsync(); rig.Project(native, current = current with { Message = "sentinel" }); await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel")); var baseEvents = events.ConditionEvents(item, "base"); if (reAckComment is null) { baseEvents.Count.ShouldBe(expected, "an uncommented re-ack must report nothing"); } else { baseEvents.Count.ShouldBe(expected + 1, "a commented re-ack reports exactly one comment event"); var commented = baseEvents[^1]; (commented[RecordedEvents.CommentField].Value as LocalizedText)?.Text.ShouldBe(reAckComment); commented[RecordedEvents.AckedField].Value.ShouldBe(true); commented[RecordedEvents.ConfirmedField].Value.ShouldBe(true, "the commented re-ack must not unconfirm the node"); } // A native re-ack is still routed to the driver: the route is fire-and-forget, so a node showing // Acked does not prove the upstream system acknowledged, and re-acking is the documented retry // (docs/AlarmTracking.md, "Best-effort during reconnect"). Pinned so it is changed deliberately. if (native) (routedAcks - routedBefore).ShouldBe(1); 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, string? ackComment = "ack") { var (methodId, args) = method switch { "Acknowledge" => (MethodIds.AcknowledgeableConditionType_Acknowledge, new object[] { eventId!, ackComment is null ? LocalizedText.Null : new LocalizedText(ackComment) }), "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); } }