diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs
index fe13c413..ebc658ed 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs
@@ -262,6 +262,12 @@ public sealed class VirtualTagActor : ReceiveActor
AlarmId: null,
EquipmentId: null);
+ // Phase 5: fan the same entry into the node-local live-telemetry hub (no-op until a gRPC client
+ // subscribes). Emitted BEFORE the publish branch so BOTH the production DPS path and the test-seam
+ // publisherFactory path (which returns early below) feed the hub. Both taps are fire-and-forget, so
+ // emit-then-publish ordering is immaterial; the DPS/factory publish itself is unchanged.
+ _telemetryHub?.Emit(new TelemetryItem.Script(entry));
+
if (_publisherFactory is not null)
{
_publisherFactory().Publish(ScriptLogsTopic, entry);
@@ -269,9 +275,6 @@ public sealed class VirtualTagActor : ReceiveActor
}
DistributedPubSub.Get(Context.System).Mediator.Tell(new Publish(ScriptLogsTopic, entry));
- // Phase 5: fan the same entry into the node-local live-telemetry hub (no-op until a gRPC client
- // subscribes). The DPS publish above is unchanged — a strictly additive tap.
- _telemetryHub?.Emit(new TelemetryItem.Script(entry));
}
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Telemetry/PublishSeamEmitsToHubTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Telemetry/PublishSeamEmitsToHubTests.cs
index 6b1d821c..61591d2f 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Telemetry/PublishSeamEmitsToHubTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Telemetry/PublishSeamEmitsToHubTests.cs
@@ -1,6 +1,8 @@
using System.Text.Json;
using Akka.Actor;
+using Akka.TestKit;
using Microsoft.EntityFrameworkCore;
+using Serilog;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
@@ -12,11 +14,16 @@ 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.Core.ScriptedAlarms;
+using ZB.MOM.WW.OtOpcUa.Core.Scripting;
+using ZB.MOM.WW.OtOpcUa.OpcUaServer;
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.Scripting;
using ZB.MOM.WW.OtOpcUa.Runtime.Telemetry;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
+using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Telemetry;
@@ -125,6 +132,100 @@ public sealed class PublishSeamEmitsToHubTests : RuntimeActorTestBase
item.E.Message.ShouldBe("temperature high");
}
+ /// The scripted-alarm host's engine-emission seam fans the SAME
+ /// it publishes to the alerts topic into the hub as a . Drives a
+ /// real engine Inactive→Active transition through the same harness the ScriptedAlarmHostActor tests use.
+ [Fact]
+ public void ScriptedAlarm_activation_emits_one_Alarm_item_with_matching_payload()
+ {
+ var publish = CreateTestProbe();
+ var mux = CreateTestProbe();
+ var hub = new CapturingHub();
+
+ var upstream = new DependencyMuxTagUpstreamSource();
+ var logger = new LoggerConfiguration().CreateLogger();
+ var engine = new ScriptedAlarmEngine(upstream, new InMemoryAlarmStateStore(), new ScriptLoggerFactory(logger), logger);
+ var host = Sys.ActorOf(ScriptedAlarmHostActor.Props(
+ publish.Ref, mux.Ref, upstream, engine, localNode: null, driverMemberCountProvider: null, telemetryHub: hub));
+
+ // One enabled alarm firing when M.T > 90; wait for load to complete (RegisterInterest lands).
+ host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { ScriptedPlan() }));
+ mux.ExpectMsg(TimeSpan.FromSeconds(8));
+
+ host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 99, DateTime.UtcNow));
+
+ // The OPC UA condition update confirms the transition was processed.
+ publish.FishForMessage(m => m.State.Active, TimeSpan.FromSeconds(8));
+
+ // Exactly one Alarm item, carrying the Activated transition the alerts topic received.
+ AwaitAssert(() => hub.Items.Count.ShouldBe(1), TimeSpan.FromSeconds(5), TimeSpan.FromMilliseconds(100));
+ var item = hub.Items[0].ShouldBeOfType();
+ item.E.AlarmId.ShouldBe("alm-1");
+ item.E.TransitionKind.ShouldBe("Activated");
+ item.E.Severity.ShouldBe(1000); // 800 → Critical bucket → 1000
+ }
+
+ // --- script-logs seam (VirtualTagActor.PublishLog) → TelemetryItem.Script --------------------------
+
+ /// The virtual-tag actor's PublishLog seam emits a into
+ /// the hub carrying the SAME instance it hands to the publisher — proven via
+ /// the test-only publisherFactory path (production uses the DPS Tell; the emit fires on both because
+ /// it precedes the early-return branch).
+ [Fact]
+ public void VirtualTag_script_log_emits_one_Script_item_with_same_payload()
+ {
+ var hub = new CapturingHub();
+ var published = new List();
+ var parent = CreateTestProbe();
+
+ var actor = parent.ChildActorOf(VirtualTagActor.Props(
+ "vt-1", "broken",
+ evaluator: new FailingEvaluator("syntax error"),
+ scriptId: "script-7",
+ publisherFactory: () => new DPSPublisher((_, payload) => published.Add((ScriptLogEntry)payload)),
+ telemetryHub: hub));
+
+ actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 1, DateTime.UtcNow));
+
+ AwaitAssert(() =>
+ {
+ published.Count.ShouldBe(1);
+ hub.Items.Count.ShouldBe(1);
+ }, TimeSpan.FromSeconds(2), TimeSpan.FromMilliseconds(100));
+
+ var item = hub.Items[0].ShouldBeOfType();
+ item.E.ShouldBeSameAs(published[0]); // the exact instance handed to the publisher
+ item.E.ScriptId.ShouldBe("script-7");
+ item.E.VirtualTagId.ShouldBe("vt-1");
+ item.E.Level.ShouldBe("Warning");
+ }
+
+ /// The M.T > 90 scripted-alarm plan shared by the activation test (mirrors the
+ /// ScriptedAlarmHostActor test harness).
+ private static EquipmentScriptedAlarmPlan ScriptedPlan() =>
+ new(
+ ScriptedAlarmId: "alm-1",
+ EquipmentId: "Plant/Line1/M",
+ Name: "HighTemp",
+ AlarmType: "AlarmCondition",
+ Severity: 800,
+ MessageTemplate: "condition",
+ PredicateScriptId: "alm-1-script",
+ PredicateSource: "return (int)ctx.GetTag(\"M.T\").Value > 90;",
+ DependencyRefs: new[] { "M.T" },
+ HistorizeToAveva: true,
+ Retain: true,
+ Enabled: true);
+
+ /// Test evaluator that always fails, to drive VirtualTagActor.PublishLog.
+ private sealed class FailingEvaluator : IVirtualTagEvaluator
+ {
+ private readonly string _reason;
+ public FailingEvaluator(string reason) => _reason = reason;
+ public VirtualTagEvalResult Evaluate(string id, string expr, IReadOnlyDictionary deps)
+ => VirtualTagEvalResult.Failure(_reason);
+ }
+
/// Spawns the host wired with the capturing hub, dispatches the deployment, and waits for the
/// Applied ack + the RebuildAddressSpace so the alarm map is built before the test raises an alarm.
private (IActorRef Actor, Akka.TestKit.TestProbe Publish) SpawnHostAndApply(