49a470a080
EventFilter.AddSelectClause(type, "ActiveState/Id") does not split the string; it selects a single QualifiedName named "ActiveState/Id", which no event carries. AckedState/Id, ActiveState/Id and EnabledState/Id therefore always arrived null and Client.CLI 'alarms' (and Client.UI) reported every alarm inactive and unacknowledged while a direct Read said Active. Pre-existing since a2883b82; server-side event delivery was correct (proven with two-element select clauses, incl. after #629). Select them with explicit two-element browse paths. Over-the-wire test drives OpcUaClientService.SubscribeAlarmsAsync against the in-process server (native + scripted); ServerEventRig now also selects ActiveState/Id, Severity and Retain. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
216 lines
12 KiB
C#
216 lines
12 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// The shared client's alarm APIs (<see cref="OpcUaClientService" />, the code behind Client.CLI's
|
|
/// <c>ack</c>/<c>confirm</c>/<c>shelve</c>) 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 <c><id>.Condition</c> / <c><id>.ShelvingState</c>
|
|
/// node ids); until this class it was only exercised against a fake session adapter.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The condition id is passed exactly as a CLI user passes <c>--node</c>: the condition node's own id.
|
|
/// Each call must return Good <b>and</b> 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).
|
|
/// </remarks>
|
|
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<VariantCollection> 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(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// The client's alarm SUBSCRIPTION (Client.CLI <c>alarms</c>) reads the condition's two-state fields off a
|
|
/// real event. Its filter used to pass <c>"ActiveState/Id"</c> to <c>EventFilter.AddSelectClause</c>, which
|
|
/// does NOT split the string: the operand's browse path was ONE QualifiedName named <c>ActiveState/Id</c>,
|
|
/// 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.
|
|
/// </summary>
|
|
/// <param name="native">Drive the native (raw device folder) or the scripted (equipment folder) condition.</param>
|
|
[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<AlarmEventArgs>();
|
|
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<OpcUaClientService> 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;
|
|
}
|
|
}
|