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; /// /// v3 Batch 4 (B4-WP4) — the equipment-tag native-alarm routing wired into /// re-expressed for the v3 raw-condition model. A native alarm is a SINGLE /// Part 9 condition materialised at the RAW tag (ConditionId = its RawPath, Raw realm) — NOT a folder-scoped /// equipment-tag NodeId. After an apply, the host's _alarmNodeIdByDriverRef map (built from the /// alarm-bearing composition.RawTags) resolves (DriverInstanceId, RawPath) to the raw /// condition NodeId, the NativeAlarmProjector projects the transition into a full /// AlarmConditionSnapshot, and ForwardNativeAlarm Tells the publish actor a single /// (Raw realm) — the fan-out to referencing equipment /// happens at the SDK-notifier level (WP4 node-manager wiring), NOT by re-reporting per root. /// /// /// Mirrors the v3 value-routing harness in DriverHostActorLiveValueTests: the seeded artifact /// carries the v3 raw-tag chain (RawFolder → DriverInstance(RawFolderId) → Device → Tag) with the alarm /// tag's TagConfig carrying an alarm object so the composer 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); // The v3 RawPath for the seeded alarm tag: RawFolder "Plant" / DriverName "Modbus" / Device "dev1" / Tag. private const string AlarmRawPath = "Plant/Modbus/dev1/temp_hi"; /// A native alarm RAISE whose ConditionId equals the alarm tag's RawPath (the v3 wire-ref) /// lands on the RAW condition NodeId (== the RawPath, Raw realm) as an /// with State.Active == true. The event carries a /// distinct SourceNodeId so the lookup is proven to key on ConditionId, not /// SourceNodeId. [Fact] public void Native_alarm_raise_routes_to_raw_condition_NodeId_active() { var db = NewInMemoryDbFactory(); var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "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: AlarmRawPath, // the v3 wire-ref RawPath = 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(AlarmRawPath); update.Realm.ShouldBe(AddressSpaceRealm.Raw); 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 = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi"); var (actor, publish) = SpawnHostAndApply(db, deploymentId); actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs( new StubAlarmHandle(), SourceNodeId: "Temp", ConditionId: "Plant/Modbus/dev1/no_such", // unmapped RawPath ⇒ drop AlarmType: "OffNormalAlarm", Message: "nope", Severity: AlarmSeverity.Low, SourceTimestampUtc: Ts, Kind: AlarmTransitionKind.Raise))); publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } /// Primary (default/unset role) fan-out: a native alarm RAISE on a known ref publishes exactly one /// to the cluster alerts topic with AlarmId = the raw /// condition NodeId (RawPath), alongside the (ungated) OPC UA condition update. [Fact] public void Native_alarm_publishes_AlarmTransitionEvent_to_alerts_when_primary() { var db = NewInMemoryDbFactory(); var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "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", ConditionId: AlarmRawPath, 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(AlarmRawPath); // Role unknown ⇒ default-emit: exactly one AlarmTransitionEvent on the alerts topic. var evt = alerts.ExpectMsg(TimeSpan.FromSeconds(5)); evt.AlarmId.ShouldBe(AlarmRawPath); // the raw condition NodeId (v3: ConditionId == RawPath) evt.EquipmentPath.ShouldBe(AlarmRawPath); // v3: the alarm meta keys the display off the RawPath evt.AlarmName.ShouldBe("temp_hi"); // from the raw 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.ShouldBeNull(); // absent historizeToAveva key ⇒ null ⇒ default-on alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); // exactly one } /// Native-alarm HistorizeToAveva opt-out: a tag whose TagConfig.alarm carries /// historizeToAveva: false publishes its with /// HistorizeToAveva == false, so the runtime's HistorianAdapterActor gate suppresses the /// durable AVEVA write. The live /alerts fan-out is unaffected. [Fact] public void Native_alarm_historizeToAveva_false_threads_through() { var db = NewInMemoryDbFactory(); var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi", historizeToAveva: false); var alerts = CreateTestProbe(); SubscribeToAlerts(alerts); var (actor, publish) = SpawnHostAndApply(db, deploymentId); actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs( new StubAlarmHandle(), SourceNodeId: "Temp", ConditionId: AlarmRawPath, AlarmType: "OffNormalAlarm", Message: "temperature high", Severity: AlarmSeverity.High, SourceTimestampUtc: Ts, Kind: AlarmTransitionKind.Raise))); var evt = alerts.ExpectMsg(TimeSpan.FromSeconds(5)); evt.AlarmId.ShouldBe(AlarmRawPath); evt.HistorizeToAveva.ShouldBe(false); } /// Native-alarm HistorizeToAveva opt-IN: an explicit historizeToAveva: true rides through as /// true (distinct from the absent ⇒ null default-on case). [Fact] public void Native_alarm_historizeToAveva_true_threads_through() { var db = NewInMemoryDbFactory(); var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi", historizeToAveva: true); var alerts = CreateTestProbe(); SubscribeToAlerts(alerts); var (actor, publish) = SpawnHostAndApply(db, deploymentId); actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs( new StubAlarmHandle(), SourceNodeId: "Temp", ConditionId: AlarmRawPath, AlarmType: "OffNormalAlarm", Message: "temperature high", Severity: AlarmSeverity.High, SourceTimestampUtc: Ts, Kind: AlarmTransitionKind.Raise))); var evt = alerts.ExpectMsg(TimeSpan.FromSeconds(5)); evt.HistorizeToAveva.ShouldBe(true); } /// Secondary suppression: when the cached local role is Secondary the host MUST still write the /// local OPC UA condition node (ungated — keeps the standby warm) 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 = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "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", ConditionId: AlarmRawPath, 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(AlarmRawPath); 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 /// ForwardNativeAlarm into the published : Comment carries /// the operator string and User is "device". [Fact] public void Native_alarm_operator_comment_flows_to_transition_event() { var db = NewInMemoryDbFactory(); var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "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", ConditionId: AlarmRawPath, AlarmType: "OffNormalAlarm", Message: "investigating", Severity: AlarmSeverity.High, SourceTimestampUtc: Ts, Kind: AlarmTransitionKind.Acknowledge, OperatorComment: "investigating"))); publish.ExpectMsg(TimeSpan.FromSeconds(5)); 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. 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 /// () with so the alerts-publish gate observes it. 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, 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 the v3 raw-tag chain (RawFolder "Plant" → /// DriverInstance(RawFolderId, Name "Modbus") → Device "dev1" → Tag) with the tag's TagConfig /// carrying an alarm object so the composer projects a non-null EquipmentTagAlarmInfo /// (OffNormalAlarm / 700) — making the raw tag a Part 9 condition at its RawPath rather than a /// value variable. Enums serialize numerically. No UNS reference is needed for these routing/emit tests /// (the SDK-notifier fan-out to referencing equipment is covered by the node-manager tests). /// private static DeploymentId SeedV3AlarmDeployment( IDbContextFactory db, RevisionHash rev, string Driver, string Tag, bool? historizeToAveva = null) { // historizeToAveva absent (null) ⇒ omit the key so the absent ⇒ historize default path is exercised; // a concrete true/false writes the bool into the alarm object so the native path threads it. object alarm = historizeToAveva is { } h ? new { alarmType = "OffNormalAlarm", severity = 700, historizeToAveva = h } : new { alarmType = "OffNormalAlarm", severity = 700 }; var artifact = JsonSerializer.SerializeToUtf8Bytes(new { RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } }, DriverInstances = new[] { new { DriverInstanceId = Driver, RawFolderId = "rf-plant", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = false }, }, 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 }), }, }, }); 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"; } }