using System.Text.Json; using Akka.Actor; using Microsoft.EntityFrameworkCore; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Engines; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet; 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.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 published by SourceNodeId (the alarm tag's FullName) lands on the /// condition's folder-scoped NodeId (here eq-1/temp_hi) as an /// with State.Active == true. [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.HiHi", ConditionId: "cond-1", 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 for a SourceNodeId 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: "NoSuch.Alarm", ConditionId: "cond-x", 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)); } /// 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"; } }