using Opc.Ua; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Client.Shared; using ZB.MOM.WW.OtOpcUa.Client.Shared.Models; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; /// /// The shared client's alarm APIs (, the code behind Client.CLI's /// ack/confirm/shelve) against the real in-process server, over the wire, with the real /// session adapter. Commit 85f00327 moved the client onto the Part 9 method ids invoked on the condition /// node itself (it used to target nonexistent <id>.Condition / <id>.ShelvingState /// node ids); until this class it was only exercised against a fake session adapter. /// /// /// The condition id is passed exactly as a CLI user passes --node: the condition node's own id. /// Each call must return Good and move the condition — read back from the condition events the /// server reports, not from the call result alone. AddComment is covered on both the scripted and the native condition (the native one supports Acknowledge and AddComment only). /// public sealed class ClientAlarmMethodTests { private const NodePermissions All = NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe | NodePermissions.AlarmRead | NodePermissions.AlarmAcknowledge | NodePermissions.AlarmConfirm | NodePermissions.AlarmShelve | NodePermissions.MethodCall; [Fact] public async Task The_client_acknowledges_confirms_and_shelves_a_scripted_condition() { var ct = TestContext.Current.CancellationToken; var rig = await StartAsync("ClientAlarmScripted", encrypted: false, ct); try { rig.Enforce(false, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All)); rig.MaterialiseScripted(); var current = ConditionMethodEventTests.PreState("Acknowledge", native: false) with { Message = "base" }; ConditionMethodEventTests.WireSimulatedOwners(rig, native: false, () => current, s => current = s); using var observer = await rig.ConnectAsync(anonymous: false, ct); var events = new RecordedEvents(); var subscription = await RecordedEvents.SubscribeAsync(observer, events, ct); var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server); await subscription.ApplyChangesAsync(ct); rig.Project(native: false, current); await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base")); using var client = await ConnectClientAsync(rig, ct); var conditionId = ConditionNodeId(observer, native: false).ToString(); async Task Next(int expectedCount) { await rig.DrainProjectionsAsync(); await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count >= expectedCount); return events.ConditionEvents(item, "base")[expectedCount - 1]; } var n = 1; StatusCode.IsGood(await client.AcknowledgeAlarmAsync( conditionId, events.LatestEventId(item, "base"), "ack", ct)).ShouldBeTrue(); (await Next(++n))[RecordedEvents.AckedField].Value.ShouldBe(true); StatusCode.IsGood(await client.ConfirmAlarmAsync( conditionId, events.LatestEventId(item, "base"), "confirm", ct)).ShouldBeTrue(); (await Next(++n))[RecordedEvents.ConfirmedField].Value.ShouldBe(true); StatusCode.IsGood(await client.AddCommentAsync( conditionId, events.LatestEventId(item, "base"), "note", ct)).ShouldBeTrue(); ((await Next(++n))[RecordedEvents.CommentField].Value as LocalizedText).ShouldNotBeNull().Text.ShouldBe("note"); StatusCode.IsGood(await client.ShelveAlarmAsync(conditionId, ShelveKind.OneShot, 0, ct)).ShouldBeTrue(); (await Next(++n))[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_OneShotShelved); StatusCode.IsGood(await client.ShelveAlarmAsync(conditionId, ShelveKind.Unshelve, 0, ct)).ShouldBeTrue(); (await Next(++n))[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved); StatusCode.IsGood(await client.ShelveAlarmAsync(conditionId, ShelveKind.Timed, 60, ct)).ShouldBeTrue(); (await Next(++n))[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_TimedShelved); StatusCode.IsGood(await client.ShelveAlarmAsync(conditionId, ShelveKind.Unshelve, 0, ct)).ShouldBeTrue(); (await Next(++n))[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved); await subscription.DeleteAsync(true, ct); } finally { await rig.DisposeAsync(); } } [Fact] public async Task The_client_acknowledges_a_native_condition_and_is_refused_confirm_and_shelve() { var ct = TestContext.Current.CancellationToken; var rig = await StartAsync("ClientAlarmNative", encrypted: false, ct); try { rig.Enforce(false, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All)); rig.MaterialiseNative(); var current = ConditionMethodEventTests.PreState("Acknowledge", native: true) with { Message = "base" }; ConditionMethodEventTests.WireSimulatedOwners(rig, native: true, () => current, s => current = s); using var observer = await rig.ConnectAsync(anonymous: false, ct); var events = new RecordedEvents(); var subscription = await RecordedEvents.SubscribeAsync(observer, 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")); using var client = await ConnectClientAsync(rig, ct); var conditionId = ConditionNodeId(observer, native: true).ToString(); var eventId = events.LatestEventId(item, "base"); // Refusals first: they must not disturb the condition, so the ack below still lands as event #2. (await client.ConfirmAlarmAsync(conditionId, eventId, "confirm", ct)).Code.ShouldBe(StatusCodes.BadNotSupported); (await client.ShelveAlarmAsync(conditionId, ShelveKind.OneShot, 0, ct)).Code.ShouldBe(StatusCodes.BadNotSupported); (await client.ShelveAlarmAsync(conditionId, ShelveKind.Timed, 60, ct)).Code.ShouldBe(StatusCodes.BadNotSupported); StatusCode.IsGood(await client.AcknowledgeAlarmAsync(conditionId, eventId, "ack", ct)).ShouldBeTrue(); await rig.DrainProjectionsAsync(); await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count >= 2); var ack = events.ConditionEvents(item, "base")[1]; ack[RecordedEvents.AckedField].Value.ShouldBe(true); ack[RecordedEvents.ConfirmedField].Value.ShouldBe(true); ack[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved); StatusCode.IsGood(await client.AddCommentAsync( conditionId, events.LatestEventId(item, "base"), "note", ct)).ShouldBeTrue(); await rig.DrainProjectionsAsync(); await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count >= 3); (events.ConditionEvents(item, "base")[2][RecordedEvents.CommentField].Value as LocalizedText).ShouldNotBeNull().Text.ShouldBe("note"); await subscription.DeleteAsync(true, ct); } finally { await rig.DisposeAsync(); } } /// /// The client's alarm SUBSCRIPTION (Client.CLI alarms) reads the condition's two-state fields off a /// real event. Its filter used to pass "ActiveState/Id" to EventFilter.AddSelectClause, which /// does NOT split the string: the operand's browse path was ONE QualifiedName named ActiveState/Id, /// which matches nothing, so every event arrived with Active/Acked/Enabled null and the client reported /// them all inactive and unacknowledged — while a direct Read of the same condition said Active. The /// fake-adapter unit tests feed field values straight in and so never exercise the filter. /// /// Drive the native (raw device folder) or the scripted (equipment folder) condition. [Theory] [InlineData(true)] [InlineData(false)] public async Task The_client_alarm_subscription_reports_active_acked_and_exact_severity(bool native) { var ct = TestContext.Current.CancellationToken; var rig = await StartAsync($"ClientAlarmSubscribe{native}", encrypted: false, ct); try { rig.Enforce(false, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All)); if (native) rig.MaterialiseNative(); else rig.MaterialiseScripted(); using var client = await ConnectClientAsync(rig, ct); using var observer = await rig.ConnectAsync(anonymous: false, ct); var source = native ? new NodeId(RawDeviceFolder, (ushort)observer.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri)) : UnsNodeId(observer, EqA); var seen = new List(); client.AlarmEvent += (_, e) => { lock (seen) seen.Add(e); }; await client.SubscribeAlarmsAsync(source, 100, ct); AlarmEventArgs? Latest(string message) { lock (seen) return seen.LastOrDefault(e => e.Message == message); } // 800 is not one of the SDK's five EventSeverity values (900 is): the node must carry it verbatim. rig.Project(native, new AlarmConditionSnapshot(Active: true, Acknowledged: false, Confirmed: true, Enabled: true, AlarmShelvingKind.Unshelved, 800, "raised")); await RecordedEvents.WaitUntilAsync(() => Latest("raised") is not null); var raised = Latest("raised")!; raised.ActiveState.ShouldBeTrue("the condition is active"); raised.AckedState.ShouldBeFalse(); raised.Severity.ShouldBe((ushort)800); raised.Retain.ShouldBeTrue(); rig.Project(native, new AlarmConditionSnapshot(Active: true, Acknowledged: true, Confirmed: true, Enabled: true, AlarmShelvingKind.Unshelved, 800, "acked")); await RecordedEvents.WaitUntilAsync(() => Latest("acked") is not null); Latest("acked")!.AckedState.ShouldBeTrue("the positive control for the Acked field"); Latest("acked")!.ActiveState.ShouldBeTrue(); rig.Project(native, new AlarmConditionSnapshot(Active: false, Acknowledged: true, Confirmed: true, Enabled: true, AlarmShelvingKind.Unshelved, 800, "cleared")); await RecordedEvents.WaitUntilAsync(() => Latest("cleared") is not null); var cleared = Latest("cleared")!; cleared.ActiveState.ShouldBeFalse(); cleared.Retain.ShouldBeFalse("an inactive, acknowledged condition is not retained"); await client.UnsubscribeAlarmsAsync(ct); } finally { await rig.DisposeAsync(); } } private static async Task ConnectClientAsync(ServerEventRig rig, CancellationToken ct) { var client = new OpcUaClientService(); await client.ConnectAsync(new ConnectionSettings { EndpointUrl = rig.EndpointUrl, Username = "apikey", Password = $"otua_{KeyId}_secret", CertificateStorePath = Path.Combine(Path.GetTempPath(), $"otopcua-clientpki-{Guid.NewGuid():N}"), }, ct); return client; } }