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; /// /// v3 Batch 4 (B4-WP4) — the inbound native-condition acknowledge routing wired into /// re-expressed for the v3 raw-condition model. An OPC UA client Acknowledges /// a NATIVE condition (materialised at the raw tag, ConditionId = its RawPath); the node manager invokes /// NativeAlarmAckRouter and the host receives a . /// The host resolves the condition NodeId (== the RawPath) → owning (DriverInstanceId, RawPath) via /// the _driverRefByAlarmNodeId inverse map (built alongside the alarm forward map in /// PushDesiredSubscriptions from the alarm-bearing composition.RawTags), applies the SAME /// primary gate the inbound write path uses, and routes to the owning driver child's /// carrying the principal. /// /// /// Mirrors DriverHostActorLiveValueTests: a real apply through the harness spawns a real /// (non-stubbed) child backed by a recording /// driver (DriverType "GalaxyMxGateway", Enabled), so the inverse map is populated authentically and the /// forwarded acknowledge request can be observed. The seeded raw tag carries an alarm object so it /// materialises as a Part 9 condition at its RawPath, 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); // The v3 RawPath for the seeded alarm tag: RawFolder "Plant" / DriverName "gw" / Device "dev1" / Tag. private const string AlarmRawPath = "Plant/gw/dev1/temp_hi"; /// On the PRIMARY (role unknown ⇒ Primary), a RouteNativeAlarmAck for a mapped raw condition NodeId /// (== the RawPath) forwards exactly one to the owning driver's /// , correlated on the RawPath, with the operator principal + the /// comment. [Fact] public void RouteNativeAlarmAck_routes_to_driver_AcknowledgeAsync_with_principal() { var db = NewInMemoryDbFactory(); var recorder = new RecordingAlarmDriverFactory("GalaxyMxGateway"); var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi"); var actor = SpawnHostAndApply(db, deploymentId, recorder); // Local role unknown ⇒ treated as Primary ⇒ ack allowed (default-allow semantics). actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "cmt", "alice")); // The driver received exactly one acknowledge, correlated on its wire-ref RawPath, with principal. AwaitAssert(() => { recorder.Acks.Count.ShouldBe(1); recorder.Acks[0].ConditionId.ShouldBe(AlarmRawPath); recorder.Acks[0].SourceNodeId.ShouldBe(AlarmRawPath); 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 = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi"); var actor = SpawnHostAndApply(db, deploymentId, recorder); actor.Tell(new DriverHostActor.RouteNativeAlarmAck("Plant/gw/dev1/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. [Fact] public void RouteNativeAlarmAck_on_non_primary_is_dropped() { var db = NewInMemoryDbFactory(); var recorder = new RecordingAlarmDriverFactory("GalaxyMxGateway"); var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "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(AlarmRawPath, "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 the v3 raw-tag chain (RawFolder "Plant" → /// DriverInstance(RawFolderId, Name "gw", DriverType "GalaxyMxGateway", Enabled) → Device "dev1" → Tag) /// with the tag's TagConfig carrying an alarm object so the composer projects a non-null /// EquipmentTagAlarmInfo — making the raw tag a Part 9 condition at its RawPath. The non-Windows /// DriverType + Enabled flag spawn a REAL child. /// private static DeploymentId SeedV3AlarmDeployment( IDbContextFactory db, RevisionHash rev, string Driver, string Tag) { var artifact = JsonSerializer.SerializeToUtf8Bytes(new { RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } }, DriverInstances = new[] { new { DriverInstanceRowId = Guid.NewGuid(), DriverInstanceId = Driver, RawFolderId = "rf-plant", Name = "gw", DriverType = "GalaxyMxGateway", // not Windows-only ⇒ a real child is spawned (not stubbed) Enabled = true, DriverConfig = "{}", ClusterId = "c1", }, }, Devices = new[] { new { DeviceId = $"{Driver}:dev1", DriverInstanceId = Driver, Name = "dev1", DeviceConfig = "{}" }, }, TagGroups = Array.Empty(), Tags = new[] { new { TagId = "tag-0", DeviceId = $"{Driver}:dev1", TagGroupId = (string?)null, Name = Tag, DataType = "Boolean", AccessLevel = 0, // TagAccessLevel.Read TagConfig = JsonSerializer.Serialize(new { 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"; } } }