Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/RecordingAddressSpaceBuilder.cs
Joseph Doherty 190d09cdeb Phase 3 PR 15 — alarm-condition contract in IAddressSpaceBuilder + wire OnAlarmEvent through GenericDriverNodeManager. IAddressSpaceBuilder.IVariableHandle gains MarkAsAlarmCondition(AlarmConditionInfo) which returns an IAlarmConditionSink. AlarmConditionInfo carries SourceName/InitialSeverity/InitialDescription. Concrete address-space builders (the upcoming PR 16 OPC UA server backend) materialize a sibling AlarmConditionState node on the first call; the sink receives every lifecycle transition the generic node manager forwards. GenericDriverNodeManager gains a CapturingBuilder wrapper that transparently wraps every Folder/Variable call — the wrapper observes MarkAsAlarmCondition calls without participating in materialization, captures the resulting IAlarmConditionSink into an internal source-node-id → sink ConcurrentDictionary keyed by IVariableHandle.FullReference. After DiscoverAsync completes, if the driver implements IAlarmSource the node manager subscribes to OnAlarmEvent and routes every AlarmEventArgs to the sink registered for args.SourceNodeId — unknown source ids are dropped silently (may belong to another driver or to a variable the builder chose not to flag). Dispose unsubscribes the forwarder to prevent dangling invocation-list references across node-manager rebuilds. GalaxyProxyDriver.DiscoverAsync now calls handle.MarkAsAlarmCondition(new AlarmConditionInfo(fullName, AlarmSeverity.Medium, null)) on every attr.IsAlarm=true variable — severity seed is Medium because the live Priority byte arrives through the subsequent GalaxyAlarmEvent stream (which PR 14's GalaxyAlarmTracker now emits); the Admin UI sees the severity update on the first transition. RecordingAddressSpaceBuilder in Driver.Galaxy.E2E gains a RecordedAlarmCondition list + a RecordingSink implementation that captures AlarmEventArgs for test assertion — the E2E parity suite can now verify alarm-condition registration shape in addition to folder/variable shape. Tests (4 new GenericDriverNodeManagerTests): Alarm_events_are_routed_to_the_sink_registered_for_the_matching_source_node_id — 2 alarms registered (Tank.HiHi + Heater.OverTemp), driver raises an event for Tank.HiHi, the Tank.HiHi sink captures the payload, the Heater.OverTemp sink does not (tag-scoped fan-out, not broadcast); Non_alarm_variables_do_not_register_sinks — plain Tank.Level in the same discover is not in TrackedAlarmSources; Unknown_source_node_id_is_dropped_silently — a transition for Unknown.Source doesn't reach any sink + no exception; Dispose_unsubscribes_from_OnAlarmEvent — post-dispose, a transition for a previously-registered tag is no-op because the forwarder detached. InternalsVisibleTo('ZB.MOM.WW.OtOpcUa.Core.Tests') added to Core csproj so TrackedAlarmSources internal property is visible to the test. Full solution: 0 errors, 152 unit tests pass (8 Core + 14 Proxy + 14 Admin + 24 Configuration + 6 Shared + 84 Galaxy.Host + 2 Server). PR 16 will implement the concrete OPC UA address-space builder that materializes AlarmConditionState from this contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 07:51:35 -04:00

71 lines
3.1 KiB
C#

using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E;
/// <summary>
/// Test-only <see cref="IAddressSpaceBuilder"/> that records every Folder + Variable
/// registration. Mirrors the v1 in-process address-space build so tests can assert on
/// the same shape the legacy <c>LmxNodeManager</c> produced.
/// </summary>
public sealed class RecordingAddressSpaceBuilder : IAddressSpaceBuilder
{
public List<RecordedFolder> Folders { get; } = new();
public List<RecordedVariable> Variables { get; } = new();
public List<RecordedProperty> Properties { get; } = new();
public List<RecordedAlarmCondition> AlarmConditions { get; } = new();
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{
Folders.Add(new RecordedFolder(browseName, displayName));
return this; // single flat builder for tests; nesting irrelevant for parity assertions
}
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
Variables.Add(new RecordedVariable(browseName, displayName, attributeInfo));
return new RecordedVariableHandle(attributeInfo.FullName, AlarmConditions);
}
public void AddProperty(string browseName, DriverDataType dataType, object? value)
{
Properties.Add(new RecordedProperty(browseName, dataType, value));
}
public sealed record RecordedFolder(string BrowseName, string DisplayName);
public sealed record RecordedVariable(string BrowseName, string DisplayName, DriverAttributeInfo AttributeInfo);
public sealed record RecordedProperty(string BrowseName, DriverDataType DataType, object? Value);
public sealed record RecordedAlarmCondition(string SourceNodeId, AlarmConditionInfo Info);
public sealed record RecordedAlarmTransition(string SourceNodeId, AlarmEventArgs Args);
/// <summary>
/// Sink the tests assert on to verify the alarm event forwarder routed a transition
/// to the correct source-node-id. One entry per <see cref="IAlarmSource.OnAlarmEvent"/>.
/// </summary>
public List<RecordedAlarmTransition> AlarmTransitions { get; } = new();
private sealed class RecordedVariableHandle : IVariableHandle
{
private readonly List<RecordedAlarmCondition> _conditions;
public string FullReference { get; }
public RecordedVariableHandle(string fullReference, List<RecordedAlarmCondition> conditions)
{
FullReference = fullReference;
_conditions = conditions;
}
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
{
_conditions.Add(new RecordedAlarmCondition(FullReference, info));
return new RecordingSink(FullReference);
}
private sealed class RecordingSink : IAlarmConditionSink
{
public string SourceNodeId { get; }
public List<AlarmEventArgs> Received { get; } = new();
public RecordingSink(string sourceNodeId) => SourceNodeId = sourceNodeId;
public void OnTransition(AlarmEventArgs args) => Received.Add(args);
}
}
}