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 { /// /// 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. /// [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? 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 SubscribeAlarmsAsync(IReadOnlyList _, CancellationToken __) => Task.FromResult(new FakeHandle("sub")); public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle _, CancellationToken __) => Task.CompletedTask; public Task AcknowledgeAsync(IReadOnlyList _, CancellationToken __) => Task.CompletedTask; } private sealed class FakeHandle(string diagnosticId) : IAlarmSubscriptionHandle { public string DiagnosticId { get; } = diagnosticId; } private sealed class RecordingBuilder : IAddressSpaceBuilder { public Dictionary 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 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 Received { get; } = new(); public void OnTransition(AlarmEventArgs args) => Received.Add(args); } } }