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:
@@ -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 > 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(
|
||||
|
||||
Reference in New Issue
Block a user