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>
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class GenericDriverNodeManagerTests
|
||||
{
|
||||
/// <summary>
|
||||
/// BuildAddressSpaceAsync walks the driver's discovery through the caller's builder. Every
|
||||
/// variable marked with MarkAsAlarmCondition captures its sink in the node manager; later,
|
||||
/// IAlarmSource.OnAlarmEvent payloads are routed by SourceNodeId to the matching sink.
|
||||
/// This is the plumbing that PR 16's concrete OPC UA builder will use to update the actual
|
||||
/// AlarmConditionState nodes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Alarm_events_are_routed_to_the_sink_registered_for_the_matching_source_node_id()
|
||||
{
|
||||
var driver = new FakeDriver();
|
||||
var builder = new RecordingBuilder();
|
||||
using var nm = new GenericDriverNodeManager(driver);
|
||||
|
||||
await nm.BuildAddressSpaceAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Alarms.Count.ShouldBe(2);
|
||||
nm.TrackedAlarmSources.Count.ShouldBe(2);
|
||||
|
||||
// Simulate the driver raising a transition for one of the alarms.
|
||||
var args = new AlarmEventArgs(
|
||||
SubscriptionHandle: new FakeHandle("s1"),
|
||||
SourceNodeId: "Tank.HiHi",
|
||||
ConditionId: "cond-1",
|
||||
AlarmType: "Tank.HiHi",
|
||||
Message: "Level exceeded",
|
||||
Severity: AlarmSeverity.High,
|
||||
SourceTimestampUtc: DateTime.UtcNow);
|
||||
driver.RaiseAlarm(args);
|
||||
|
||||
builder.Alarms["Tank.HiHi"].Received.Count.ShouldBe(1);
|
||||
builder.Alarms["Tank.HiHi"].Received[0].Message.ShouldBe("Level exceeded");
|
||||
// The other alarm sink never received a payload — fan-out is tag-scoped.
|
||||
builder.Alarms["Heater.OverTemp"].Received.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Non_alarm_variables_do_not_register_sinks()
|
||||
{
|
||||
var driver = new FakeDriver();
|
||||
var builder = new RecordingBuilder();
|
||||
using var nm = new GenericDriverNodeManager(driver);
|
||||
|
||||
await nm.BuildAddressSpaceAsync(builder, CancellationToken.None);
|
||||
|
||||
// FakeDriver registers 2 alarm-bearing variables + 1 plain variable.
|
||||
nm.TrackedAlarmSources.ShouldNotContain("Tank.Level"); // the plain one
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_source_node_id_is_dropped_silently()
|
||||
{
|
||||
var driver = new FakeDriver();
|
||||
var builder = new RecordingBuilder();
|
||||
using var nm = new GenericDriverNodeManager(driver);
|
||||
await nm.BuildAddressSpaceAsync(builder, CancellationToken.None);
|
||||
|
||||
driver.RaiseAlarm(new AlarmEventArgs(
|
||||
new FakeHandle("s1"), "Unknown.Source", "c", "t", "m", AlarmSeverity.Low, DateTime.UtcNow));
|
||||
|
||||
builder.Alarms.Values.All(s => s.Received.Count == 0).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispose_unsubscribes_from_OnAlarmEvent()
|
||||
{
|
||||
var driver = new FakeDriver();
|
||||
var builder = new RecordingBuilder();
|
||||
var nm = new GenericDriverNodeManager(driver);
|
||||
await nm.BuildAddressSpaceAsync(builder, CancellationToken.None);
|
||||
|
||||
nm.Dispose();
|
||||
|
||||
driver.RaiseAlarm(new AlarmEventArgs(
|
||||
new FakeHandle("s1"), "Tank.HiHi", "c", "t", "m", AlarmSeverity.Low, DateTime.UtcNow));
|
||||
|
||||
// No sink should have received it — the forwarder was detached.
|
||||
builder.Alarms["Tank.HiHi"].Received.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
// --- test doubles ---
|
||||
|
||||
private sealed class FakeDriver : IDriver, ITagDiscovery, IAlarmSource
|
||||
{
|
||||
public string DriverInstanceId => "fake";
|
||||
public string DriverType => "Fake";
|
||||
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
|
||||
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct)
|
||||
{
|
||||
var folder = builder.Folder("Tank", "Tank");
|
||||
var lvl = folder.Variable("Level", "Level", new DriverAttributeInfo(
|
||||
"Tank.Level", DriverDataType.Float64, false, null, SecurityClassification.FreeAccess, false, IsAlarm: false));
|
||||
var hiHi = folder.Variable("HiHi", "HiHi", new DriverAttributeInfo(
|
||||
"Tank.HiHi", DriverDataType.Boolean, false, null, SecurityClassification.FreeAccess, false, IsAlarm: true));
|
||||
hiHi.MarkAsAlarmCondition(new AlarmConditionInfo("Tank.HiHi", AlarmSeverity.High, "High-high alarm"));
|
||||
|
||||
var heater = builder.Folder("Heater", "Heater");
|
||||
var ot = heater.Variable("OverTemp", "OverTemp", new DriverAttributeInfo(
|
||||
"Heater.OverTemp", DriverDataType.Boolean, false, null, SecurityClassification.FreeAccess, false, IsAlarm: true));
|
||||
ot.MarkAsAlarmCondition(new AlarmConditionInfo("Heater.OverTemp", AlarmSeverity.Critical, "Over-temperature"));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void RaiseAlarm(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args);
|
||||
|
||||
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(IReadOnlyList<string> _, CancellationToken __)
|
||||
=> Task.FromResult<IAlarmSubscriptionHandle>(new FakeHandle("sub"));
|
||||
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle _, CancellationToken __) => Task.CompletedTask;
|
||||
public Task AcknowledgeAsync(IReadOnlyList<AlarmAcknowledgeRequest> _, CancellationToken __) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class FakeHandle(string diagnosticId) : IAlarmSubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId { get; } = diagnosticId;
|
||||
}
|
||||
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public Dictionary<string, RecordingSink> Alarms { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public IAddressSpaceBuilder Folder(string _, string __) => this;
|
||||
|
||||
public IVariableHandle Variable(string _, string __, DriverAttributeInfo info)
|
||||
=> new Handle(info.FullName, Alarms);
|
||||
|
||||
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
||||
|
||||
public sealed class Handle(string fullRef, Dictionary<string, RecordingSink> alarms) : IVariableHandle
|
||||
{
|
||||
public string FullReference { get; } = fullRef;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo _)
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
alarms[FullReference] = sink;
|
||||
return sink;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RecordingSink : IAlarmConditionSink
|
||||
{
|
||||
public List<AlarmEventArgs> Received { get; } = new();
|
||||
public void OnTransition(AlarmEventArgs args) => Received.Add(args);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user