Task #219 follow-up — close AlarmConditionState child-NodeId + event-propagation gaps
PR #197 surfaced two integration-level wiring gaps in DriverNodeManager's MarkAsAlarmCondition path; this commit fixes both and upgrades the integration test to assert them end-to-end. Fix 1 — addressable child nodes: AlarmConditionState inherits ~50 typed children (Severity / Message / ActiveState / AckedState / EnabledState / …). The stack was leaving them with Foundation-namespace NodeIds (type-declaration defaults) or shared ns=0 counter allocations, so client Read on a child returned BadNodeIdUnknown. Pass assignNodeIds=true to alarm.Create, then walk the condition subtree and rewrite each descendant's NodeId symbolically as {condition-full-ref}.{symbolic-path} in the node manager's namespace. Stable, unique, and collision-free across multiple alarm instances in the same driver. Fix 2 — event propagation to Server.EventNotifier: OPC UA Part 9 event propagation relies on the alarm condition being reachable from Objects/Server via HasNotifier. Call CustomNodeManager2.AddRootNotifier(alarm) after registering the condition so subscriptions placed on Server-object EventNotifier receive the ReportEvent calls ConditionSink emits per-transition. Test upgrades in AlarmSubscribeIntegrationTests: - Driver_alarm_transition_updates_server_side_AlarmConditionState_node — now asserts Severity == 700, Message text, and ActiveState.Id == true through the OPC UA client (previously scoped out as BadNodeIdUnknown). - New: Driver_alarm_event_flows_to_client_subscription_on_Server_EventNotifier subscribes an OPC UA event monitor on ObjectIds.Server, fires a driver transition, and waits for the AlarmConditionType event to be delivered, asserting Message + Severity fields. Previously scoped out as "Part 9 event propagation out of reach." Regression checks: 239 server tests pass (+1 new event-subscription test), 195 Core tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,27 +12,17 @@ using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task #219 — server-integration coverage for the <see cref="IAlarmSource"/> wiring
|
||||
/// path. Boots the full OPC UA stack + a fake <see cref="IAlarmSource"/> driver, opens a
|
||||
/// client session, and verifies via browse/read that DiscoverAsync's
|
||||
/// <c>MarkAsAlarmCondition</c> calls produce addressable AlarmConditionState nodes in the
|
||||
/// driver's namespace and that firing <c>OnAlarmEvent</c> routes through
|
||||
/// <c>GenericDriverNodeManager</c>'s forwarder into <c>DriverNodeManager.ConditionSink</c>
|
||||
/// without throwing.
|
||||
/// Task #219 — end-to-end server integration coverage for the <see cref="IAlarmSource"/>
|
||||
/// dispatch path. Boots the full OPC UA stack + a fake <see cref="IAlarmSource"/> driver,
|
||||
/// opens a client session, raises a driver-side transition, and asserts it propagates
|
||||
/// through <c>GenericDriverNodeManager</c>'s alarm forwarder into
|
||||
/// <c>DriverNodeManager.ConditionSink</c>, updates the server-side
|
||||
/// <c>AlarmConditionState</c> child attributes (Severity / Message / ActiveState), and
|
||||
/// flows out to an OPC UA subscription on the Server object's EventNotifier.
|
||||
///
|
||||
/// Companion to <see cref="HistoryReadIntegrationTests"/> which covers the
|
||||
/// <see cref="IHistoryProvider"/> dispatch path; together they close the server-side
|
||||
/// integration gap for optional driver capabilities (plan decision #62).
|
||||
///
|
||||
/// Known server-side scoping (not a regression introduced here): the stack exposes the
|
||||
/// AlarmConditionState type's inherited children (Severity / Message / ActiveState / …)
|
||||
/// with Foundation-namespace NodeIds (ns=0) that aren't added to
|
||||
/// <see cref="DriverNodeManager"/>'s predefined-node index, so reading those child
|
||||
/// attributes through an OPC UA client returns <c>BadNodeIdUnknown</c>. OPC UA Part 9
|
||||
/// event propagation (subscribe-on-Server + ConditionRefresh) is likewise out of scope
|
||||
/// until the node manager wires <c>HasNotifier</c> + child-node registration. The
|
||||
/// existing Core-level <c>GenericDriverNodeManagerTests</c> cover the in-memory alarm-sink
|
||||
/// fan-out semantics directly.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class AlarmSubscribeIntegrationTests : IAsyncLifetime
|
||||
@@ -116,32 +106,96 @@ public sealed class AlarmSubscribeIntegrationTests : IAsyncLifetime
|
||||
r => ExpandedNodeId.ToNodeId(r.NodeId, session.NamespaceUris),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
children.ShouldContainKey("Severity",
|
||||
"browse did not return Severity child; full list: "
|
||||
+ string.Join(", ", browseResults[0].References.Select(r => $"{r.BrowseName.Name}={r.NodeId}")));
|
||||
children.ShouldContainKey("Severity");
|
||||
children.ShouldContainKey("Message");
|
||||
children.ShouldContainKey("ActiveState");
|
||||
|
||||
// NB: the stack exposes AlarmConditionState's inherited children with Foundation-namespace
|
||||
// NodeIds (ns=0). The DriverNodeManager registers only the parent alarm object via
|
||||
// AddPredefinedNode; reading child attributes through the OPC UA client returns
|
||||
// BadNodeIdUnknown because the stack-assigned child NodeIds aren't in the node
|
||||
// manager's predefined-node index. Asserting state-mutation via a client-side read
|
||||
// is therefore out of reach at this integration layer — the Core-level
|
||||
// GenericDriverNodeManagerTests cover the in-memory alarm-sink fan-out directly.
|
||||
//
|
||||
// What this test *does* verify through the OPC UA client: the alarm node itself is
|
||||
// reachable via browse (proving MarkAsAlarmCondition registered it as a predefined
|
||||
// node), its displayed name matches the driver's AlarmConditionInfo.SourceName, and
|
||||
// firing the transition does not throw out of ConditionSink.OnTransition (which would
|
||||
// fail the test at RaiseAlarm since the event handler is invoked synchronously).
|
||||
var nodesToRead = new ReadValueIdCollection
|
||||
// Severity / Message / ActiveState.Id reflect the driver-fired transition — verifies
|
||||
// the forwarder → ConditionSink.OnTransition → alarm.ClearChangeMasks pipeline
|
||||
// landed the new values in addressable child nodes. DriverNodeManager's
|
||||
// AssignSymbolicDescendantIds keeps each child reachable under the node manager's
|
||||
// namespace so Read resolves against the predefined-node dictionary.
|
||||
var severity = session.ReadValue(children["Severity"]);
|
||||
var message = session.ReadValue(children["Message"]);
|
||||
severity.Value.ShouldBe((ushort)700); // AlarmSeverity.High → 700 (MapSeverity)
|
||||
((LocalizedText)message.Value).Text.ShouldBe("Level exceeded upper-upper");
|
||||
|
||||
// ActiveState exposes its boolean Id as a HasProperty child.
|
||||
var activeBrowse = new BrowseDescriptionCollection
|
||||
{
|
||||
new() { NodeId = conditionNodeId, AttributeId = Attributes.DisplayName },
|
||||
new()
|
||||
{
|
||||
NodeId = children["ActiveState"],
|
||||
BrowseDirection = BrowseDirection.Forward,
|
||||
ReferenceTypeId = ReferenceTypeIds.HasProperty,
|
||||
IncludeSubtypes = true,
|
||||
ResultMask = (uint)BrowseResultMask.All,
|
||||
},
|
||||
};
|
||||
session.Read(null, 0, TimestampsToReturn.Neither, nodesToRead, out var values, out _);
|
||||
values[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
((LocalizedText)values[0].Value).Text.ShouldBe("Tank.HiHi");
|
||||
session.Browse(null, null, 0, activeBrowse, out var activeChildren, out _);
|
||||
var idRef = activeChildren[0].References.Single(r => r.BrowseName.Name == "Id");
|
||||
var activeId = session.ReadValue(ExpandedNodeId.ToNodeId(idRef.NodeId, session.NamespaceUris));
|
||||
activeId.Value.ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Driver_alarm_event_flows_to_client_subscription_on_Server_EventNotifier()
|
||||
{
|
||||
// AddRootNotifier registers the AlarmConditionState as a Server-object notifier
|
||||
// source, so a subscription with an EventFilter on Server receives the
|
||||
// ReportEvent calls ConditionSink emits per-transition.
|
||||
using var session = await OpenSessionAsync();
|
||||
|
||||
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
|
||||
session.AddSubscription(subscription);
|
||||
await subscription.CreateAsync();
|
||||
|
||||
var received = new List<EventFieldList>();
|
||||
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
var filter = new EventFilter();
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.EventId);
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.SourceName);
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Message);
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Severity);
|
||||
filter.WhereClause = new ContentFilter();
|
||||
filter.WhereClause.Push(FilterOperator.OfType,
|
||||
new LiteralOperand { Value = new Variant(ObjectTypeIds.AlarmConditionType) });
|
||||
|
||||
var item = new MonitoredItem(subscription.DefaultItem)
|
||||
{
|
||||
StartNodeId = ObjectIds.Server,
|
||||
AttributeId = Attributes.EventNotifier,
|
||||
NodeClass = NodeClass.Object,
|
||||
SamplingInterval = 0,
|
||||
QueueSize = 100,
|
||||
Filter = filter,
|
||||
};
|
||||
item.Notification += (_, e) =>
|
||||
{
|
||||
if (e.NotificationValue is EventFieldList fields)
|
||||
{
|
||||
lock (received) { received.Add(fields); gate.TrySetResult(); }
|
||||
}
|
||||
};
|
||||
subscription.AddItem(item);
|
||||
await subscription.ApplyChangesAsync();
|
||||
|
||||
// Give the publish loop a tick to establish before firing.
|
||||
await Task.Delay(200);
|
||||
|
||||
_driver.RaiseAlarm(new AlarmEventArgs(
|
||||
new FakeHandle("sub"), "Tank.HiHi", "cond-x", "Active",
|
||||
"High-high tripped", AlarmSeverity.Critical, DateTime.UtcNow));
|
||||
|
||||
var delivered = await Task.WhenAny(gate.Task, Task.Delay(TimeSpan.FromSeconds(10)));
|
||||
delivered.ShouldBe(gate.Task, "alarm event must arrive at the client within 10s");
|
||||
|
||||
EventFieldList first;
|
||||
lock (received) first = received[0];
|
||||
// Filter field order: 0=EventId, 1=SourceName, 2=Message, 3=Severity.
|
||||
((LocalizedText)first.EventFields[2].Value).Text.ShouldBe("High-high tripped");
|
||||
first.EventFields[3].Value.ShouldBe((ushort)900); // Critical → 900
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user