From 909d75357bd1b39020f8f57fc440fba09911e797 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 23 Jul 2026 15:38:12 -0400 Subject: [PATCH] test(mesh-phase5): cover the ScriptedAlarmHostActor + VirtualTagActor telemetry taps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes two test-completeness gaps flagged in review of the seam-tap commit — the ScriptedAlarmHostActor and VirtualTagActor hub emits had no test proving the hub actually receives them. - ScriptedAlarm_activation_emits_one_Alarm_item_with_matching_payload: spawns the host with a capturing fake hub, drives a real engine Inactive->Active transition through the existing ScriptedAlarms harness, and asserts exactly one TelemetryItem.Alarm carrying the Activated transition. - VirtualTag_script_log_emits_one_Script_item_with_same_payload: passes a fake hub via Props + a publisherFactory, drives an evaluator failure, and asserts a TelemetryItem.Script carrying the SAME ScriptLogEntry instance handed to the publisher. To make the VirtualTagActor tap observable, its hub emit moved to the TOP of PublishLog, before the test-only publisherFactory early-return branch, so the emit fires on BOTH the production DPS path and the factory path. Production behavior is unchanged: production passes publisherFactory: null, so it still reaches the DPS Tell; the emit just moved a couple lines earlier (both are fire-and-forget). Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../VirtualTags/VirtualTagActor.cs | 9 +- .../Telemetry/PublishSeamEmitsToHubTests.cs | 101 ++++++++++++++++++ 2 files changed, 107 insertions(+), 3 deletions(-) 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(