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)); } /// Gitea #641 (item 4) — routing is an EXACT match on the RawPath: a driver that decorates its /// ConditionId (AbCip used to send "{RawPath}#active", FOCAS "{host}#{type}:{number}" before #647) has /// every transition dropped, so a native source MUST report ConditionId = RawPath. [Fact] public void A_decorated_condition_id_is_dropped() { 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(), AlarmRawPath, $"{AlarmRawPath}#active", "ALMD", "decorated", AlarmSeverity.High, Ts, Kind: AlarmTransitionKind.Raise))); // Positive control: the undecorated id routes, and is the first update. actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs( new StubAlarmHandle(), AlarmRawPath, AlarmRawPath, "ALMD", "plain", AlarmSeverity.High, Ts, Kind: AlarmTransitionKind.Raise))); publish.ExpectMsg(TimeSpan.FromSeconds(5)).State.Message.ShouldBe("plain"); } /// 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"); } /// Gitea #626 — a QUALITY-ONLY native event (an kind /// carrying , e.g. an MTConnect condition gone Unavailable while /// already Normal) re-annotates the condition's Quality through the quality-only node path /// ( — NOT a projected snapshot), and publishes NO /// alerts row — it is not an alarm transition. The positive control is the Raise that follows: it /// MUST publish, and it must be the FIRST alerts message, so the absence is proven rather than timed. [Fact] public void Quality_only_native_event_updates_the_condition_but_publishes_no_alerts_row() { 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: "SYSTEM", Message: "SYSTEM UNAVAILABLE", Severity: AlarmSeverity.Low, SourceTimestampUtc: Ts, Kind: AlarmTransitionKind.Unspecified, QualityStatusCode: OpcUaStatus.BadNoCommunication))); var annotated = publish.ExpectMsg(TimeSpan.FromSeconds(5)); annotated.AlarmNodeId.ShouldBe(AlarmRawPath); annotated.Realm.ShouldBe(AddressSpaceRealm.Raw); annotated.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication); // Positive control: a real transition after it DOES publish, and is the first thing on the topic. actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs( new StubAlarmHandle(), SourceNodeId: "Temp", ConditionId: AlarmRawPath, AlarmType: "SYSTEM", Message: "spindle fault", Severity: AlarmSeverity.High, SourceTimestampUtc: Ts, Kind: AlarmTransitionKind.Raise, OpcUaSeverity: 800, QualityStatusCode: OpcUaStatus.Good))); var raised = publish.ExpectMsg(TimeSpan.FromSeconds(5)); raised.State.Active.ShouldBeTrue(); raised.State.Severity.ShouldBe((ushort)800); raised.State.StatusCode.ShouldBe(OpcUaStatus.Good); var evt = alerts.ExpectMsg(TimeSpan.FromSeconds(5)); evt.Message.ShouldBe("spindle fault", "the quality-only annotation must not have published an alerts row"); evt.TransitionKind.ShouldBe("Activated"); evt.Severity.ShouldBe(800); } /// Gitea #626 (review MINOR 1) — after a deploy (which clears the projector's prior state), a /// quality-only event on a raised, UNACKNOWLEDGED condition must not reach the node as a snapshot: a projected /// "keep the state" would default to inactive+acked, silently acknowledging it and rewriting its message and /// severity. Only an (which writes Quality alone) may be /// sent. Proven by order: the next message after it is the positive control's state update. [Fact] public void Quality_only_event_never_sends_a_state_snapshot_that_could_ack_or_rewrite() { 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(), "Temp", AlarmRawPath, "SYSTEM", "spindle fault", AlarmSeverity.High, Ts, Kind: AlarmTransitionKind.Raise, OpcUaSeverity: 800, QualityStatusCode: OpcUaStatus.Good))); publish.ExpectMsg(TimeSpan.FromSeconds(5)).State.Acknowledged.ShouldBeFalse(); // Whatever the projector's prior state is (any apply clears it), a quality-only event must never be // projected — the routing below is the whole guard, so no deploy needs staging here. actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs( new StubAlarmHandle(), "Temp", AlarmRawPath, "SYSTEM", "spindle fault", AlarmSeverity.High, Ts, Kind: AlarmTransitionKind.Unspecified, OpcUaSeverity: 800, QualityStatusCode: OpcUaStatus.BadNoCommunication))); // Positive control. actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs( new StubAlarmHandle(), "Temp", AlarmRawPath, "SYSTEM", "control", AlarmSeverity.High, Ts, Kind: AlarmTransitionKind.Retrigger, OpcUaSeverity: 800, QualityStatusCode: OpcUaStatus.Good))); var next = publish.FishForMessage( m => m is OpcUaPublishActor.AlarmQualityUpdate or OpcUaPublishActor.AlarmStateUpdate, TimeSpan.FromSeconds(5)); next.ShouldBeOfType().StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication); var control = publish.FishForMessage( m => m is OpcUaPublishActor.AlarmQualityUpdate or OpcUaPublishActor.AlarmStateUpdate, TimeSpan.FromSeconds(5)); control.ShouldBeOfType().State.Message.ShouldBe("control", "the quality-only event must produce no state snapshot ahead of the control"); } /// #477 Layer 2 — a driver annotates the /// Quality of the driver's native conditions via : comms /// lost → Bad, restored → Good, at the raw condition NodeId + realm. No alarm transition is involved — this /// is the ONLY signal for a comms-lost native source. [Fact] public void Driver_disconnect_and_reconnect_annotate_native_condition_quality() { var db = NewInMemoryDbFactory(); var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi"); var (actor, publish) = SpawnHostAndApply(db, deploymentId); // Comms lost → the raw condition is annotated BadNoCommunication (#519: the reason survives to // the client; this used to be a bare Bad indistinguishable from any other fault). actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-1", Connected: false)); var bad = publish.ExpectMsg(TimeSpan.FromSeconds(5)); bad.AlarmNodeId.ShouldBe(AlarmRawPath); bad.Realm.ShouldBe(AddressSpaceRealm.Raw); bad.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication); // Comms restored → annotated Good. actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-1", Connected: true)); var good = publish.ExpectMsg(TimeSpan.FromSeconds(5)); good.AlarmNodeId.ShouldBe(AlarmRawPath); good.StatusCode.ShouldBe(OpcUaStatus.Good); } /// #477 Layer 2 — connectivity for a DIFFERENT driver instance annotates none of this driver's /// conditions (the fan-out is scoped by DriverInstanceId). [Fact] public void Connectivity_for_another_driver_annotates_nothing() { var db = NewInMemoryDbFactory(); var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi"); var (actor, publish) = SpawnHostAndApply(db, deploymentId); actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-OTHER", Connected: false)); publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } /// /// Gitea #641 — a redeploy that keeps the alarm must not reset the projector's prior state for it. It used /// to be cleared on EVERY apply, so a driver Acknowledge arriving after any deploy projected /// (prev.Active = false, Acked) and wrote an ACTIVE alarm inactive. /// [Fact] public void An_acknowledge_after_a_redeploy_keeps_the_alarm_active() { var db = NewInMemoryDbFactory(); var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi"); var (actor, publish, coordinator) = SpawnHostAndApplyWithCoordinator(db, deploymentId); actor.Tell(Transition(AlarmTransitionKind.Raise)); publish.ExpectMsg(TimeSpan.FromSeconds(5)).State.Active.ShouldBeTrue(); Redeploy(db, actor, publish, coordinator); actor.Tell(Transition(AlarmTransitionKind.Acknowledge)); var acked = publish.FishForMessage(_ => true, TimeSpan.FromSeconds(5)); acked.State.Active.ShouldBeTrue("the alarm is still active at the device"); acked.State.Acknowledged.ShouldBeTrue(); } /// Gitea #641 — likewise a Clear after a redeploy must not silently acknowledge an unacknowledged /// alarm (the cleared prior state defaulted Acked to true). [Fact] public void A_clear_after_a_redeploy_keeps_the_alarm_unacknowledged() { var db = NewInMemoryDbFactory(); var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi"); var (actor, publish, coordinator) = SpawnHostAndApplyWithCoordinator(db, deploymentId); actor.Tell(Transition(AlarmTransitionKind.Raise)); publish.ExpectMsg(TimeSpan.FromSeconds(5)).State.Acknowledged.ShouldBeFalse(); Redeploy(db, actor, publish, coordinator); actor.Tell(Transition(AlarmTransitionKind.Clear)); var cleared = publish.FishForMessage(_ => true, TimeSpan.FromSeconds(5)); cleared.State.Active.ShouldBeFalse(); cleared.State.Acknowledged.ShouldBeFalse("nobody acknowledged it"); } /// /// Gitea #641 review — a CLIENT acknowledgement is applied to the node by the SDK and only ROUTED here; the /// projector must learn of it. Before #641 the projector was cleared on every deploy, so a Clear arriving /// after one defaulted Acked to true and happened to be right for a client-acked alarm; now that the /// projector survives the deploy, a projector that never heard the ack projects the Clear as UNacked and /// un-acknowledges the alarm the operator acknowledged. /// [Fact] public void A_clear_after_a_client_acknowledge_and_a_redeploy_stays_acknowledged() { var db = NewInMemoryDbFactory(); var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi"); var (actor, publish, coordinator) = SpawnHostAndApplyWithCoordinator(db, deploymentId); actor.Tell(Transition(AlarmTransitionKind.Raise)); publish.ExpectMsg(TimeSpan.FromSeconds(5)).State.Acknowledged.ShouldBeFalse(); // The client's Acknowledge on the node, routed toward the driver (no transition comes back for it). actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "seen", "op")); Redeploy(db, actor, publish, coordinator); actor.Tell(Transition(AlarmTransitionKind.Clear)); var cleared = publish.FishForMessage(_ => true, TimeSpan.FromSeconds(5)); cleared.State.Active.ShouldBeFalse(); cleared.State.Acknowledged.ShouldBeTrue("the operator acknowledged it before it cleared"); } private static DriverInstanceActor.AttributeAlarmPublished Transition(AlarmTransitionKind kind) => new("drv-1", new AlarmEventArgs( new StubAlarmHandle(), "Temp", AlarmRawPath, "OffNormalAlarm", kind.ToString(), AlarmSeverity.High, Ts, Kind: kind)); /// A second deployment carrying the SAME alarm (a new revision of an identical artifact). private void Redeploy( IDbContextFactory db, IActorRef actor, TestProbe publish, TestProbe coordinator) { var revB = RevisionHash.Parse(new string('b', 64)); var second = SeedV3AlarmDeployment(db, revB, Driver: "drv-1", Tag: "temp_hi"); actor.Tell(new DispatchDeployment(second, revB, CorrelationId.NewId())); coordinator.FishForMessage(a => a.DeploymentId == second, TimeSpan.FromSeconds(5)) .Outcome.ShouldBe(ApplyAckOutcome.Applied); publish.FishForMessage(_ => true, TimeSpan.FromSeconds(5)); } /// 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, IsDriverPrimary: 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 (actor, publish, _) = SpawnHostAndApplyWithCoordinator(db, deploymentId); return (actor, publish); } private (IActorRef Actor, TestProbe Publish, TestProbe Coordinator) SpawnHostAndApplyWithCoordinator( IDbContextFactory db, DeploymentId deploymentId) { var coordinator = CreateTestProbe(); var publish = this.CreateCleanOutcomePublishProbe(); 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, coordinator); } /// /// 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"; } }