test(mesh-phase5): cover the ScriptedAlarmHostActor + VirtualTagActor telemetry taps

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
This commit is contained in:
Joseph Doherty
2026-07-23 15:38:12 -04:00
parent 7bdb6d00ec
commit 909d75357b
2 changed files with 107 additions and 3 deletions
@@ -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));
}
}
@@ -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");
}
/// <summary>The scripted-alarm host's engine-emission seam fans the SAME <see cref="AlarmTransitionEvent"/>
/// it publishes to the <c>alerts</c> topic into the hub as a <see cref="TelemetryItem.Alarm"/>. Drives a
/// real engine Inactive→Active transition through the same harness the ScriptedAlarmHostActor tests use.</summary>
[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<DependencyMuxActor.RegisterInterest>(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<OpcUaPublishActor.AlarmStateUpdate>(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<TelemetryItem.Alarm>();
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 --------------------------
/// <summary>The virtual-tag actor's <c>PublishLog</c> seam emits a <see cref="TelemetryItem.Script"/> into
/// the hub carrying the SAME <see cref="ScriptLogEntry"/> instance it hands to the publisher — proven via
/// the test-only <c>publisherFactory</c> path (production uses the DPS Tell; the emit fires on both because
/// it precedes the early-return branch).</summary>
[Fact]
public void VirtualTag_script_log_emits_one_Script_item_with_same_payload()
{
var hub = new CapturingHub();
var published = new List<ScriptLogEntry>();
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<TelemetryItem.Script>();
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");
}
/// <summary>The <c>M.T &gt; 90</c> scripted-alarm plan shared by the activation test (mirrors the
/// ScriptedAlarmHostActor test harness).</summary>
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);
/// <summary>Test evaluator that always fails, to drive <c>VirtualTagActor.PublishLog</c>.</summary>
private sealed class FailingEvaluator : IVirtualTagEvaluator
{
private readonly string _reason;
public FailingEvaluator(string reason) => _reason = reason;
public VirtualTagEvalResult Evaluate(string id, string expr, IReadOnlyDictionary<string, object?> deps)
=> VirtualTagEvalResult.Failure(_reason);
}
/// <summary>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.</summary>
private (IActorRef Actor, Akka.TestKit.TestProbe Publish) SpawnHostAndApply(