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); } /// Verifies that non-alarm variables do not register sinks in the alarm tracker. [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 } /// Verifies that alarm events with unknown source node IDs are silently dropped. [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(); } /// Verifies that disposing the node manager unsubscribes from alarm events. [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); } /// /// Core-006 regression: a second call to BuildAddressSpaceAsync (e.g. on Galaxy redeploy) /// must unsubscribe the old alarm forwarder and clear the sink registry before re-walking, /// so alarm transitions are not delivered twice. /// [Fact] public async Task Second_BuildAddressSpaceAsync_Does_Not_Double_Fire_Alarms() { var driver = new FakeDriver(); var builder1 = new RecordingBuilder(); var builder2 = new RecordingBuilder(); using var nm = new GenericDriverNodeManager(driver); await nm.BuildAddressSpaceAsync(builder1, CancellationToken.None); await nm.BuildAddressSpaceAsync(builder2, CancellationToken.None); // redeploy driver.RaiseAlarm(new AlarmEventArgs( new FakeHandle("s1"), "Tank.HiHi", "c", "t", "m", AlarmSeverity.High, DateTime.UtcNow)); // Only the second builder's sink should have received the event. builder2.Alarms["Tank.HiHi"].Received.Count.ShouldBe(1, "second BuildAddressSpaceAsync must replace the subscription — not add to it"); // The first builder's sink should NOT have received it (old forwarder was detached). builder1.Alarms.TryGetValue("Tank.HiHi", out var oldSink); (oldSink?.Received.Count ?? 0).ShouldBe(0, "the original alarm forwarder must be unsubscribed on the second build"); } /// Verifies that a second call to BuildAddressSpaceAsync clears the old sink registry. [Fact] public async Task Second_BuildAddressSpaceAsync_Clears_Old_Sink_Registry() { var driver = new FakeDriver(); using var nm = new GenericDriverNodeManager(driver); await nm.BuildAddressSpaceAsync(new RecordingBuilder(), CancellationToken.None); var countAfterFirst = nm.TrackedAlarmSources.Count; await nm.BuildAddressSpaceAsync(new RecordingBuilder(), CancellationToken.None); var countAfterSecond = nm.TrackedAlarmSources.Count; countAfterFirst.ShouldBe(2, "FakeDriver registers 2 alarm sources"); countAfterSecond.ShouldBe(2, "second build must re-register exactly the same sources, not accumulate"); } /// Verifies that calling BuildAddressSpaceAsync after disposal throws ObjectDisposedException. [Fact] public async Task BuildAddressSpaceAsync_After_Dispose_Throws_ObjectDisposedException() { var driver = new FakeDriver(); var nm = new GenericDriverNodeManager(driver); nm.Dispose(); await Should.ThrowAsync(() => nm.BuildAddressSpaceAsync(new RecordingBuilder(), CancellationToken.None)); } /// /// Core-008 regression: the XML doc states exception isolation is the caller's /// responsibility — exceptions from must propagate /// out of BuildAddressSpaceAsync unhandled so the Server layer's per-driver try/catch /// (OpcUaApplicationHost.PopulateAddressSpaces) can mark the subtree Faulted. /// [Fact] public async Task BuildAddressSpaceAsync_Propagates_Discovery_Exceptions_To_Caller() { var driver = new ThrowingDiscoveryDriver(); using var nm = new GenericDriverNodeManager(driver); var ex = await Should.ThrowAsync(() => nm.BuildAddressSpaceAsync(new RecordingBuilder(), CancellationToken.None)); ex.Message.ShouldBe("discovery boom", "exceptions from DiscoverAsync must propagate unhandled — exception isolation is the caller's responsibility (e.g. OpcUaApplicationHost)"); } /// Driver whose DiscoverAsync throws — exercises the exception-isolation boundary. private sealed class ThrowingDiscoveryDriver : IDriver, ITagDiscovery { /// Gets the driver instance identifier. public string DriverInstanceId => "throwing"; /// Gets the driver type name. public string DriverType => "Throwing"; /// Initializes the driver with configuration. /// Configuration JSON (unused in test double). /// Cancellation token (unused in test double). public Task InitializeAsync(string _, CancellationToken __) => Task.CompletedTask; /// Reinitializes the driver with new configuration. /// Configuration JSON (unused in test double). /// Cancellation token (unused in test double). public Task ReinitializeAsync(string _, CancellationToken __) => Task.CompletedTask; /// Shuts down the driver. /// Cancellation token (unused in test double). public Task ShutdownAsync(CancellationToken _) => Task.CompletedTask; /// Gets the current health status of the driver. public DriverHealth GetHealth() => new(DriverState.Healthy, null, null); /// Gets the memory footprint of the driver. public long GetMemoryFootprint() => 0; /// Flushes optional caches in the driver. /// Cancellation token (unused in test double). public Task FlushOptionalCachesAsync(CancellationToken _) => Task.CompletedTask; /// Discovers the address space by throwing an exception. /// The builder used to construct the address space. /// Cancellation token. public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct) => throw new InvalidOperationException("discovery boom"); } // --- test doubles --- private sealed class FakeDriver : IDriver, ITagDiscovery, IAlarmSource { /// Gets the driver instance identifier. public string DriverInstanceId => "fake"; /// Gets the driver type name. public string DriverType => "Fake"; /// Occurs when an alarm event is raised. public event EventHandler? OnAlarmEvent; /// Initializes the driver with configuration. /// Configuration JSON. /// Cancellation token. public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask; /// Reinitializes the driver with new configuration. /// Configuration JSON. /// Cancellation token. public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask; /// Shuts down the driver. /// Cancellation token. public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask; /// Gets the current health status of the driver. public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null); /// Gets the memory footprint of the driver. public long GetMemoryFootprint() => 0; /// Flushes optional caches in the driver. /// Cancellation token. public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask; /// Discovers the address space and registers alarm conditions. /// The builder used to construct the address space. /// Cancellation token. 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; } /// Raises an alarm event with the given arguments. /// The alarm event arguments. public void RaiseAlarm(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args); /// Subscribes to alarm events. /// Tag references to subscribe to (unused in test double). /// Cancellation token (unused in test double). public Task SubscribeAlarmsAsync(IReadOnlyList _, CancellationToken __) => Task.FromResult(new FakeHandle("sub")); /// Unsubscribes from alarm events. /// The subscription handle (unused in test double). /// Cancellation token (unused in test double). public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle _, CancellationToken __) => Task.CompletedTask; /// Acknowledges alarm notifications. /// Alarm acknowledgement requests (unused in test double). /// Cancellation token (unused in test double). public Task AcknowledgeAsync(IReadOnlyList _, CancellationToken __) => Task.CompletedTask; } /// Test double for IAlarmSubscriptionHandle. private sealed class FakeHandle(string diagnosticId) : IAlarmSubscriptionHandle { /// Gets the diagnostic identifier for this subscription. public string DiagnosticId { get; } = diagnosticId; } /// Test double for IAddressSpaceBuilder that records alarm sinks. private sealed class RecordingBuilder : IAddressSpaceBuilder { /// Gets the map of alarm sources to their sinks. public Dictionary Alarms { get; } = new(StringComparer.OrdinalIgnoreCase); /// Creates a folder in the address space. /// The contained name (unused in test double). /// The display name (unused in test double). public IAddressSpaceBuilder Folder(string _, string __) => this; /// Creates a variable in the address space. /// The contained name (unused in test double). /// The display name (unused in test double). /// The driver attribute information. public IVariableHandle Variable(string _, string __, DriverAttributeInfo info) => new Handle(info.FullName, Alarms); /// Adds a property to the current variable. /// The property name (unused in test double). /// The data type (unused in test double). /// The initial value (unused in test double). public void AddProperty(string _, DriverDataType __, object? ___) { } /// Test double for IVariableHandle. public sealed class Handle(string fullRef, Dictionary alarms) : IVariableHandle { /// Gets the full reference name for this variable. public string FullReference { get; } = fullRef; /// Marks this variable as an alarm condition and registers its sink. /// The alarm condition info (unused in test double). public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo _) { var sink = new RecordingSink(); alarms[FullReference] = sink; return sink; } } /// Test double for IAlarmConditionSink that records transitions. public sealed class RecordingSink : IAlarmConditionSink { /// Gets the list of alarm transitions received by this sink. public List Received { get; } = new(); /// Records an alarm transition. /// The alarm event arguments. public void OnTransition(AlarmEventArgs args) => Received.Add(args); } } }