using System.Text.Json; using Akka.Actor; using Akka.Cluster.Tools.PublishSubscribe; using Akka.TestKit; using Microsoft.EntityFrameworkCore; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Engines; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts; 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.OpcUa; 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.OpcUa; using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms; using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; /// /// Verifies the equipment-tag native-alarm routing wired into /// (Phase B WS-4c, the LIVE-CONDITION half): a driver child publishes a native alarm transition as /// keyed by the alarm source's /// SourceNodeId (the equipment tag's wire-ref FullName), but the materialised condition /// lives at a FOLDER-SCOPED NodeId ({equipmentId}/{folderPath}/{name}). After an apply, the /// host's _alarmNodeIdByDriverRef map (built only from alarm-bearing EquipmentTags) resolves /// (DriverInstanceId, SourceNodeId) to that NodeId, the NativeAlarmProjector projects /// the transition into a full AlarmConditionSnapshot, and ForwardNativeAlarm Tells the /// publish actor an — the same message scripted /// alarms use. /// /// /// Mirrors the value-routing harness in DriverHostActorLiveValueTests: the seeded artifact /// carries the Namespaces / DriverInstances / Tags arrays, with each alarm /// tag's TagConfig carrying an alarm object so /// DeploymentArtifact.ExtractTagAlarm projects a non-null /// EquipmentTagAlarmInfo. The OPC UA sink + dependency mux are injected as TestProbes. /// /// public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase { private static readonly NodeId TestNode = NodeId.Parse("driver-alarm-test"); private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64)); private static readonly DateTime Ts = new(2026, 6, 14, 10, 0, 0, DateTimeKind.Utc); /// A native alarm RAISE whose ConditionId equals the alarm tag's FullName lands on /// the condition's folder-scoped NodeId (here eq-1/temp_hi) as an /// with State.Active == true. The event carries a /// production-shaped SourceNodeId (the bare owning object, distinct from ConditionId) so the /// lookup is proven to key on ConditionId, not SourceNodeId. [Fact] public void Native_alarm_raise_routes_to_folder_scoped_condition_NodeId_active() { var db = NewInMemoryDbFactory(); // One alarm-bearing equipment tag: eq-1, drv-1, FullName "Temp.HiHi", no folder, Name "temp_hi". var deploymentId = SeedDeploymentWithAlarmTag(db, RevA, Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi"); var (actor, publish) = SpawnHostAndApply(db, deploymentId); actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs( new StubAlarmHandle(), SourceNodeId: "Temp", // bare owning object (SourceObjectReference) — NOT the lookup key ConditionId: "Temp.HiHi", // dotted alarm full-reference = the authored FullName (the lookup key) AlarmType: "OffNormalAlarm", Message: "temperature high", Severity: AlarmSeverity.High, SourceTimestampUtc: Ts, Kind: AlarmTransitionKind.Raise))); var update = publish.ExpectMsg(TimeSpan.FromSeconds(5)); update.AlarmNodeId.ShouldBe("eq-1/temp_hi"); update.State.Active.ShouldBeTrue(); update.State.Acknowledged.ShouldBeFalse(); update.TimestampUtc.ShouldBe(Ts); } /// An whose ConditionId is not in /// the alarm map produces NO (unknown-ref drop). [Fact] public void Unknown_alarm_ref_produces_no_AlarmStateUpdate() { var db = NewInMemoryDbFactory(); var deploymentId = SeedDeploymentWithAlarmTag(db, RevA, Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi"); var (actor, publish) = SpawnHostAndApply(db, deploymentId); actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs( new StubAlarmHandle(), SourceNodeId: "Temp", // owning object exists, but the condition ref below is unmapped ConditionId: "NoSuch.HiHi", // dotted ref not in the alarm map ⇒ drop AlarmType: "OffNormalAlarm", Message: "nope", Severity: AlarmSeverity.Low, SourceTimestampUtc: Ts, Kind: AlarmTransitionKind.Raise))); // No alarm-condition NodeId for ("drv-1","NoSuch.Alarm") → nothing reaches the sink. publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } /// Primary (default/unset role) fan-out (Phase B WS-5): a native alarm RAISE on a known ref /// publishes exactly one to the cluster alerts topic with /// AlarmId = the folder-scoped condition NodeId, alongside the (ungated) OPC UA condition /// update. No is sent, so the cached role is unknown ⇒ emit. [Fact] public void Native_alarm_publishes_AlarmTransitionEvent_to_alerts_when_primary() { var db = NewInMemoryDbFactory(); var deploymentId = SeedDeploymentWithAlarmTag(db, RevA, Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi"); var alerts = CreateTestProbe(); SubscribeToAlerts(alerts); var (actor, publish) = SpawnHostAndApply(db, deploymentId); actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs( new StubAlarmHandle(), SourceNodeId: "Temp", // bare owning object (SourceObjectReference) — NOT the lookup key ConditionId: "Temp.HiHi", // dotted alarm full-reference = the authored FullName (the lookup key) AlarmType: "OffNormalAlarm", Message: "temperature high", Severity: AlarmSeverity.High, SourceTimestampUtc: Ts, Kind: AlarmTransitionKind.Raise))); // The OPC UA condition update is UNGATED — it must arrive. var update = publish.ExpectMsg(TimeSpan.FromSeconds(5)); update.AlarmNodeId.ShouldBe("eq-1/temp_hi"); // Role unknown ⇒ default-emit: exactly one AlarmTransitionEvent on the alerts topic. var evt = alerts.ExpectMsg(TimeSpan.FromSeconds(5)); evt.AlarmId.ShouldBe("eq-1/temp_hi"); // the folder-scoped condition NodeId evt.EquipmentPath.ShouldBe("eq-1"); // from the alarm-bearing tag's EquipmentId evt.AlarmName.ShouldBe("temp_hi"); // from the tag's Name evt.TransitionKind.ShouldBe("Activated"); // native Kind → canonical EmissionKind vocabulary (Raise → Activated) evt.AlarmTypeName.ShouldBe("OffNormalAlarm"); // the tag's alarm AlarmType evt.Severity.ShouldBe(700); // AlarmSeverity.High → projector 700 evt.Message.ShouldBe("temperature high"); evt.User.ShouldBe(string.Empty); // no operator comment ⇒ device-origin (empty user) evt.HistorizeToAveva.ShouldBe(true); alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); // exactly one } /// Secondary suppression (Phase B WS-5): when the cached local role is Secondary the host /// MUST still write the local OPC UA condition node (ungated — keeps the standby's address space warm /// for failover) but MUST NOT publish the cluster-wide alerts transition (the Primary publishes /// the single fleet-wide copy). [Fact] public void Secondary_node_suppresses_alerts_publish_but_still_updates_condition() { var db = NewInMemoryDbFactory(); var deploymentId = SeedDeploymentWithAlarmTag(db, RevA, Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi"); var alerts = CreateTestProbe(); SubscribeToAlerts(alerts); var (actor, publish) = SpawnHostAndApply(db, deploymentId); // Mark this node Secondary so the alerts publish is gated off. TellRedundancyRole(actor, RedundancyRole.Secondary); actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs( new StubAlarmHandle(), SourceNodeId: "Temp", // bare owning object (SourceObjectReference) — NOT the lookup key ConditionId: "Temp.HiHi", // dotted alarm full-reference = the authored FullName (the lookup key) AlarmType: "OffNormalAlarm", Message: "temperature high", Severity: AlarmSeverity.High, SourceTimestampUtc: Ts, Kind: AlarmTransitionKind.Raise))); // The OPC UA condition update is UNGATED — it must still arrive on the secondary. var update = publish.ExpectMsg(TimeSpan.FromSeconds(5)); update.AlarmNodeId.ShouldBe("eq-1/temp_hi"); update.State.Active.ShouldBeTrue(); // The cluster-wide alerts publish is gated off on the secondary. alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } /// A native alarm whose AlarmEventArgs.OperatorComment is set flows through /// DriverHostActor.ForwardNativeAlarm into the published : /// Comment carries the operator string and User is "device" (a non-null comment /// signals the upstream alarm system provided an operator origin, but without a specific user identity). /// [Fact] public void Native_alarm_operator_comment_flows_to_transition_event() { var db = NewInMemoryDbFactory(); var deploymentId = SeedDeploymentWithAlarmTag(db, RevA, Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi"); var alerts = CreateTestProbe(); SubscribeToAlerts(alerts); var (actor, publish) = SpawnHostAndApply(db, deploymentId); // Send an alarm whose OperatorComment is set — simulates an upstream acknowledge-with-comment. actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs( new StubAlarmHandle(), SourceNodeId: "Temp", ConditionId: "Temp.HiHi", AlarmType: "OffNormalAlarm", Message: "investigating", Severity: AlarmSeverity.High, SourceTimestampUtc: Ts, Kind: AlarmTransitionKind.Acknowledge, OperatorComment: "investigating"))); // OPC UA condition update is ungated — drain it. publish.ExpectMsg(TimeSpan.FromSeconds(5)); // The published AlarmTransitionEvent must carry the comment + the "device" user marker. var evt = alerts.ExpectMsg(TimeSpan.FromSeconds(5)); evt.Comment.ShouldBe("investigating"); evt.User.ShouldBe("device"); } /// Subscribe to the alerts DPS topic and wait for the ack. /// The Subscribe is sent FROM the probe so the SubscribeAck returns to it. Mirrors the /// ScriptedAlarmHostActor test harness. private void SubscribeToAlerts(TestProbe probe) { DistributedPubSub.Get(Sys).Mediator.Tell( new Subscribe(ScriptedAlarmHostActor.AlertsTopic, probe.Ref), probe.Ref); probe.ExpectMsg(TimeSpan.FromSeconds(5)); } /// Tell the host a snapshot marking the host's own /// node (, which equals the host's _localNode) with /// so the alerts-publish gate observes the local role. private static void TellRedundancyRole(IActorRef host, RedundancyRole role) { host.Tell(new RedundancyStateChanged( new[] { new NodeRedundancyState( NodeId: TestNode, Role: role, IsClusterLeader: role == RedundancyRole.Primary, IsRoleLeaderForDriver: role == RedundancyRole.Primary, AsOfUtc: DateTime.UtcNow), }, CorrelationId.NewId())); } /// Spawns the host with a publish probe, dispatches the deployment, and waits for the Applied /// ACK so the apply (and thus the alarm-map build in PushDesiredSubscriptions) has completed before the /// test publishes an alarm. A VirtualTag-host probe is injected so the real host isn't spawned. private (IActorRef Actor, Akka.TestKit.TestProbe Publish) SpawnHostAndApply( IDbContextFactory db, DeploymentId deploymentId) { var coordinator = CreateTestProbe(); var publish = CreateTestProbe(); var mux = CreateTestProbe(); var vtHost = CreateTestProbe(); var actor = Sys.ActorOf(DriverHostActor.Props( db, TestNode, coordinator.Ref, localRoles: new HashSet { "driver" }, dependencyMux: mux.Ref, opcUaPublishActor: publish.Ref, virtualTagEvaluator: NullVirtualTagEvaluator.Instance, virtualTagHostOverride: vtHost.Ref)); actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); coordinator.ExpectMsg(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied); // RebuildAddressSpace also lands on the publish probe during apply; drain it so the test's // ExpectMsg assertions see only alarm updates. publish.ExpectMsg(TimeSpan.FromSeconds(5)); return (actor, publish); } /// /// Seeds a Sealed deployment whose artifact carries one alarm-bearing equipment tag: the tag's /// TagConfig carries both a FullName and an alarm object /// (alarmType + severity) so DeploymentArtifact.ExtractTagAlarm projects a /// non-null EquipmentTagAlarmInfo (here OffNormalAlarm / 700) — making the tag a /// condition rather than a value variable. /// 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 { DriverInstanceId = Driver, 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; } /// Minimal for building . private sealed class StubAlarmHandle : IAlarmSubscriptionHandle { /// Gets the diagnostic ID of the alarm subscription. public string DiagnosticId => "stub-alarm-sub"; } }