using System.Collections.Concurrent; using System.Text.Json; using Akka.Actor; using Microsoft.EntityFrameworkCore; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Engines; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; /// /// Verifies the inbound native-condition acknowledge routing wired into /// (H6d): an OPC UA client Acknowledges a NATIVE condition, the /// node manager invokes NativeAlarmAckRouter, and the host (NEXT task) Tells a /// in. The host resolves the condition NodeId → /// owning (DriverInstanceId, FullName) via the _driverRefByAlarmNodeId inverse map /// (built alongside the alarm forward map in PushDesiredSubscriptions), applies the SAME /// primary gate the inbound write path uses, and routes to the owning driver child's /// carrying the principal. /// /// /// Mirrors DriverHostActorWriteRoutingTests: a real apply through the existing harness /// spawns a real (non-stubbed) child backed by a recording /// driver, so the inverse map is populated authentically and the /// forwarded acknowledge request can be observed. The seeded tag carries an alarm object so /// it materialises as a Part 9 condition (folder-scoped condition NodeId), not a value variable. /// /// public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTestBase { private static readonly NodeId TestNode = NodeId.Parse("driver-ack-test"); private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64)); private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); /// On the PRIMARY (role unknown ⇒ Primary), a RouteNativeAlarmAck for a mapped condition NodeId /// forwards exactly one to the owning driver's /// , with ConditionId == FullName, the operator /// principal, and the comment. [Fact] public void RouteNativeAlarmAck_routes_to_driver_AcknowledgeAsync_with_principal() { var db = NewInMemoryDbFactory(); var recorder = new RecordingAlarmDriverFactory("GalaxyMxGateway"); // One alarm-bearing equipment tag: eq-1, drv-1, FullName "Temp.HiHi", no folder, Name "temp_hi" // → condition NodeId "eq-1/temp_hi". var deploymentId = SeedDeploymentWithAlarmTag(db, RevA, Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi"); var actor = SpawnHostAndApply(db, deploymentId, recorder); // Local role unknown ⇒ treated as Primary ⇒ ack allowed (default-allow semantics). actor.Tell(new DriverHostActor.RouteNativeAlarmAck("eq-1/temp_hi", "cmt", "alice")); // The driver received exactly one acknowledge, correlated on its wire-ref FullName, with principal. AwaitAssert(() => { recorder.Acks.Count.ShouldBe(1); recorder.Acks[0].ConditionId.ShouldBe("Temp.HiHi"); recorder.Acks[0].SourceNodeId.ShouldBe("Temp.HiHi"); recorder.Acks[0].Comment.ShouldBe("cmt"); recorder.Acks[0].OperatorUser.ShouldBe("alice"); }, duration: Timeout); } /// An ack for an unmapped condition NodeId is dropped: no throw, and the driver's /// is never called. [Fact] public void RouteNativeAlarmAck_unknown_node_is_dropped() { var db = NewInMemoryDbFactory(); var recorder = new RecordingAlarmDriverFactory("GalaxyMxGateway"); var deploymentId = SeedDeploymentWithAlarmTag(db, RevA, Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi"); var actor = SpawnHostAndApply(db, deploymentId, recorder); actor.Tell(new DriverHostActor.RouteNativeAlarmAck("eq-1/does-not-exist", "cmt", "alice")); // Give the (fire-and-forget) handler time to run; the unmapped node must produce no ack. AwaitAssert(() => recorder.Acks.ShouldBeEmpty(), duration: TimeSpan.FromMilliseconds(800)); } /// On a SECONDARY node the ack is gated off (same primary gate as the inbound write path): the /// driver's is NOT called — a secondary keeps its address /// space warm but must not push commands to the shared upstream alarm system. [Fact] public void RouteNativeAlarmAck_on_non_primary_is_dropped() { var db = NewInMemoryDbFactory(); var recorder = new RecordingAlarmDriverFactory("GalaxyMxGateway"); var deploymentId = SeedDeploymentWithAlarmTag(db, RevA, Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi"); var actor = SpawnHostAndApply(db, deploymentId, recorder); // Force this node Secondary so the primary gate rejects the ack. actor.Tell(new RedundancyStateChanged( new[] { new NodeRedundancyState(TestNode, RedundancyRole.Secondary, IsClusterLeader: false, IsRoleLeaderForDriver: false, AsOfUtc: DateTime.UtcNow), }, CorrelationId.NewId())); actor.Tell(new DriverHostActor.RouteNativeAlarmAck("eq-1/temp_hi", "cmt", "alice")); // No ack reached the driver — the gate short-circuited before the inverse-map lookup. AwaitAssert(() => recorder.Acks.ShouldBeEmpty(), duration: TimeSpan.FromMilliseconds(800)); } /// Spawns the host with the recording alarm-driver factory, dispatches the deployment, and waits /// for the Applied ACK so the apply (and thus the inverse-map build in PushDesiredSubscriptions) has /// completed before the test routes an ack. private IActorRef SpawnHostAndApply( IDbContextFactory db, DeploymentId deploymentId, IDriverFactory factory) { var coordinator = CreateTestProbe(); var actor = Sys.ActorOf(DriverHostActor.Props( db, TestNode, coordinator.Ref, driverFactory: factory, localRoles: new HashSet { "driver" })); actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); return actor; } /// /// Seeds a Sealed deployment whose artifact carries one alarm-bearing equipment tag: the tag's /// TagConfig carries both a FullName and an alarm object so /// DeploymentArtifact.ExtractTagAlarm projects a non-null EquipmentTagAlarmInfo — /// making the tag a condition (folder-scoped condition NodeId) rather than a value variable. The /// DriverInstances row carries a non-Windows-only DriverType ("GalaxyMxGateway") + an /// Enabled flag so a REAL (non-stubbed) child is spawned. /// private static DeploymentId SeedDeploymentWithAlarmTag( IDbContextFactory db, RevisionHash rev, string Equip, string Driver, string FullName, string? Folder, string Name) { var artifact = JsonSerializer.SerializeToUtf8Bytes(new { Namespaces = new[] { new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0 }, DriverInstances = new[] { new { DriverInstanceRowId = Guid.NewGuid(), DriverInstanceId = Driver, Name = Driver, DriverType = "GalaxyMxGateway", // not Windows-only ⇒ a real child is spawned (not stubbed) Enabled = true, DriverConfig = "{}", NamespaceId = "ns-eq", }, }, Tags = new[] { new { TagId = "tag-0", EquipmentId = Equip, DriverInstanceId = Driver, Name, FolderPath = Folder, DataType = "Boolean", TagConfig = JsonSerializer.Serialize(new { FullName, alarm = new { alarmType = "OffNormalAlarm", severity = 700 }, }), }, }, }); var id = DeploymentId.NewId(); using var ctx = db.CreateDbContext(); ctx.Deployments.Add(new Deployment { DeploymentId = id.Value, RevisionHash = rev.Value, Status = DeploymentStatus.Sealed, CreatedBy = "test", SealedAtUtc = DateTime.UtcNow, ArtifactBlob = artifact, }); ctx.SaveChanges(); return id; } /// Factory producing a single for the supported type, whose /// recorded acknowledge list is exposed for assertions. private sealed class RecordingAlarmDriverFactory : IDriverFactory { private readonly string _supportedType; private readonly RecordingAlarmDriver _driver = new(); public RecordingAlarmDriverFactory(string supportedType) { _supportedType = supportedType; } /// The acknowledge requests the spawned driver received (thread-safe — AcknowledgeAsync runs /// off the actor thread). public IReadOnlyList Acks => _driver.Acks; /// public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) { if (!string.Equals(driverType, _supportedType, StringComparison.Ordinal)) return null; _driver.Bind(driverInstanceId, driverType); return _driver; } /// public IReadOnlyCollection SupportedTypes => new[] { _supportedType }; } /// An + that records every acknowledge. private sealed class RecordingAlarmDriver : IDriver, IAlarmSource { private readonly ConcurrentQueue _acks = new(); /// public string DriverInstanceId { get; private set; } = string.Empty; /// public string DriverType { get; private set; } = string.Empty; /// The acknowledge requests received so far. public IReadOnlyList Acks => _acks.ToArray(); /// Sets the identity once the factory is asked to create it. public void Bind(string id, string type) { DriverInstanceId = id; DriverType = type; } /// public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask; /// public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask; /// public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask; /// public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, LastError: null); /// public long GetMemoryFootprint() => 0; /// public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; /// public Task SubscribeAlarmsAsync( IReadOnlyList sourceNodeIds, CancellationToken cancellationToken) => Task.FromResult(new StubAlarmHandle()); /// public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) => Task.CompletedTask; /// public Task AcknowledgeAsync( IReadOnlyList acknowledgements, CancellationToken cancellationToken) { foreach (var a in acknowledgements) _acks.Enqueue(a); return Task.CompletedTask; } /// public event EventHandler? OnAlarmEvent { add { } remove { } } private sealed class StubAlarmHandle : IAlarmSubscriptionHandle { public string DiagnosticId => "stub-alarm-sub"; } } }