Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs
T

348 lines
17 KiB
C#

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;
/// <summary>
/// Verifies the equipment-tag <b>native-alarm routing</b> wired into <see cref="DriverHostActor"/>
/// (Phase B WS-4c, the LIVE-CONDITION half): a driver child publishes a native alarm transition as
/// <see cref="DriverInstanceActor.AttributeAlarmPublished"/> keyed by the alarm source's
/// <c>SourceNodeId</c> (the equipment tag's wire-ref <c>FullName</c>), but the materialised condition
/// lives at a FOLDER-SCOPED NodeId (<c>{equipmentId}/{folderPath}/{name}</c>). After an apply, the
/// host's <c>_alarmNodeIdByDriverRef</c> map (built only from alarm-bearing EquipmentTags) resolves
/// <c>(DriverInstanceId, SourceNodeId)</c> to that NodeId, the <c>NativeAlarmProjector</c> projects
/// the transition into a full <c>AlarmConditionSnapshot</c>, and <c>ForwardNativeAlarm</c> Tells the
/// publish actor an <see cref="OpcUaPublishActor.AlarmStateUpdate"/> — the same message scripted
/// alarms use.
///
/// <para>
/// Mirrors the value-routing harness in <c>DriverHostActorLiveValueTests</c>: the seeded artifact
/// carries the <c>Namespaces</c> / <c>DriverInstances</c> / <c>Tags</c> arrays, with each alarm
/// tag's <c>TagConfig</c> carrying an <c>alarm</c> object so
/// <c>DeploymentArtifact.ExtractTagAlarm</c> projects a non-null
/// <c>EquipmentTagAlarmInfo</c>. The OPC UA sink + dependency mux are injected as TestProbes.
/// </para>
/// </summary>
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);
/// <summary>A native alarm RAISE whose <c>ConditionId</c> equals the alarm tag's <c>FullName</c> lands on
/// the condition's folder-scoped NodeId (here <c>eq-1/temp_hi</c>) as an
/// <see cref="OpcUaPublishActor.AlarmStateUpdate"/> with <c>State.Active == true</c>. The event carries a
/// production-shaped <c>SourceNodeId</c> (the bare owning object, distinct from <c>ConditionId</c>) so the
/// lookup is proven to key on <c>ConditionId</c>, not <c>SourceNodeId</c>.</summary>
[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<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5));
update.AlarmNodeId.ShouldBe("eq-1/temp_hi");
update.State.Active.ShouldBeTrue();
update.State.Acknowledged.ShouldBeFalse();
update.TimestampUtc.ShouldBe(Ts);
}
/// <summary>An <see cref="DriverInstanceActor.AttributeAlarmPublished"/> whose <c>ConditionId</c> is not in
/// the alarm map produces NO <see cref="OpcUaPublishActor.AlarmStateUpdate"/> (unknown-ref drop).</summary>
[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));
}
/// <summary>Primary (default/unset role) fan-out (Phase B WS-5): a native alarm RAISE on a known ref
/// publishes exactly one <see cref="AlarmTransitionEvent"/> to the cluster <c>alerts</c> topic with
/// <c>AlarmId</c> = the folder-scoped condition NodeId, alongside the (ungated) OPC UA condition
/// update. No <see cref="RedundancyStateChanged"/> is sent, so the cached role is unknown ⇒ emit.</summary>
[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<OpcUaPublishActor.AlarmStateUpdate>(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<AlarmTransitionEvent>(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
}
/// <summary>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 <c>alerts</c> transition (the Primary publishes
/// the single fleet-wide copy).</summary>
[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<OpcUaPublishActor.AlarmStateUpdate>(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));
}
/// <summary>A native alarm whose <c>AlarmEventArgs.OperatorComment</c> is set flows through
/// <c>DriverHostActor.ForwardNativeAlarm</c> into the published <see cref="AlarmTransitionEvent"/>:
/// <c>Comment</c> carries the operator string and <c>User</c> is <c>"device"</c> (a non-null comment
/// signals the upstream alarm system provided an operator origin, but without a specific user identity).
/// </summary>
[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<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5));
// The published AlarmTransitionEvent must carry the comment + the "device" user marker.
var evt = alerts.ExpectMsg<AlarmTransitionEvent>(TimeSpan.FromSeconds(5));
evt.Comment.ShouldBe("investigating");
evt.User.ShouldBe("device");
}
/// <summary>Subscribe <paramref name="probe"/> to the <c>alerts</c> 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.</summary>
private void SubscribeToAlerts(TestProbe probe)
{
DistributedPubSub.Get(Sys).Mediator.Tell(
new Subscribe(ScriptedAlarmHostActor.AlertsTopic, probe.Ref), probe.Ref);
probe.ExpectMsg<SubscribeAck>(TimeSpan.FromSeconds(5));
}
/// <summary>Tell the host a <see cref="RedundancyStateChanged"/> snapshot marking the host's own
/// node (<see cref="TestNode"/>, which equals the host's <c>_localNode</c>) with <paramref name="role"/>
/// so the alerts-publish gate observes the local role.</summary>
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()));
}
/// <summary>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.</summary>
private (IActorRef Actor, Akka.TestKit.TestProbe Publish) SpawnHostAndApply(
IDbContextFactory<OtOpcUaConfigDbContext> 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<string> { "driver" },
dependencyMux: mux.Ref,
opcUaPublishActor: publish.Ref,
virtualTagEvaluator: NullVirtualTagEvaluator.Instance,
virtualTagHostOverride: vtHost.Ref));
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
// RebuildAddressSpace also lands on the publish probe during apply; drain it so the test's
// ExpectMsg<AlarmStateUpdate> assertions see only alarm updates.
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(TimeSpan.FromSeconds(5));
return (actor, publish);
}
/// <summary>
/// Seeds a Sealed deployment whose artifact carries one alarm-bearing equipment tag: the tag's
/// <c>TagConfig</c> carries both a <c>FullName</c> and an <c>alarm</c> object
/// (<c>alarmType</c> + <c>severity</c>) so <c>DeploymentArtifact.ExtractTagAlarm</c> projects a
/// non-null <c>EquipmentTagAlarmInfo</c> (here <c>OffNormalAlarm</c> / 700) — making the tag a
/// condition rather than a value variable.
/// </summary>
private static DeploymentId SeedDeploymentWithAlarmTag(
IDbContextFactory<OtOpcUaConfigDbContext> 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;
}
/// <summary>Minimal <see cref="IAlarmSubscriptionHandle"/> for building <see cref="AlarmEventArgs"/>.</summary>
private sealed class StubAlarmHandle : IAlarmSubscriptionHandle
{
/// <summary>Gets the diagnostic ID of the alarm subscription.</summary>
public string DiagnosticId => "stub-alarm-sub";
}
}