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