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);
}
}
}