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.Alerts; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging; 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.Scripting; using ZB.MOM.WW.OtOpcUa.Runtime.Telemetry; using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Telemetry; /// /// Per-cluster mesh Phase 5 — the four telemetry publish seams each additionally fan their DPS payload /// into the node-local . Each test drives a producer with a capturing /// fake hub and asserts exactly one of the correct subtype carrying the /// SAME payload that goes onto the DistributedPubSub topic. The DPS publish itself is unchanged and is /// covered by the existing per-producer tests — these only assert the additive tap. /// public sealed class PublishSeamEmitsToHubTests : RuntimeActorTestBase { private static readonly NodeId TestNode = NodeId.Parse("telemetry-seam-test"); private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64)); private static readonly DateTime Ts = new(2026, 7, 23, 10, 0, 0, DateTimeKind.Utc); private const string AlarmRawPath = "Plant/Modbus/dev1/temp_hi"; // --- driver-health seam (AkkaDriverHealthPublisher) → TelemetryItem.Health -------------------------- /// The driver-health publisher fans the SAME it publishes to /// the driver-health DPS topic into the hub as a . [Fact] public void Driver_health_publish_emits_one_Health_item_with_same_payload() { var hub = new CapturingHub(); var publisher = new AkkaDriverHealthPublisher(Sys, hub); var health = new DriverHealth(DriverState.Degraded, Ts, "some error"); publisher.Publish("c1", "drv-1", health, errorCount5Min: 4); hub.Items.Count.ShouldBe(1); var item = hub.Items[0].ShouldBeOfType(); item.E.ClusterId.ShouldBe("c1"); item.E.DriverInstanceId.ShouldBe("drv-1"); item.E.State.ShouldBe(DriverState.Degraded.ToString()); item.E.LastError.ShouldBe("some error"); item.E.ErrorCount5Min.ShouldBe(4); } // --- script-logs seam (DpsScriptLogPublisher) → TelemetryItem.Script ------------------------------- /// The DPS script-log publisher fans the SAME instance it publishes /// to the script-logs topic into the hub as a . [Fact] public void Script_log_publish_emits_one_Script_item_with_same_payload() { var hub = new CapturingHub(); var publisher = new DpsScriptLogPublisher(() => Sys, hub); var entry = new ScriptLogEntry( ScriptId: "S1", Level: "Information", Message: "hello", TimestampUtc: Ts, VirtualTagId: "V1", AlarmId: null, EquipmentId: "EQ1"); publisher.Publish(entry); hub.Items.Count.ShouldBe(1); var item = hub.Items[0].ShouldBeOfType(); item.E.ShouldBeSameAs(entry); // the exact instance published to DPS } /// A null hub is rejected at construction (fail-fast, DI provides a real hub on driver nodes). [Fact] public void Script_log_publisher_rejects_null_hub() { Should.Throw(() => new DpsScriptLogPublisher(() => Sys, null!)); } // --- alerts seam (DriverHostActor native alarm) → TelemetryItem.Alarm ------------------------------ /// A Primary-gated native-alarm transition fans the SAME it /// publishes to the alerts topic into the hub as a . Proves the /// hub was threaded through 's Props into the emit seam. [Fact] public void Native_alarm_publish_emits_one_Alarm_item_with_matching_payload() { var db = NewInMemoryDbFactory(); var deploymentId = SeedV3AlarmDeployment(db, RevA); var hub = new CapturingHub(); var (actor, publish) = SpawnHostAndApply(db, deploymentId, hub); actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs( new StubAlarmHandle(), SourceNodeId: "Temp", ConditionId: AlarmRawPath, AlarmType: "OffNormalAlarm", Message: "temperature high", Severity: AlarmSeverity.High, SourceTimestampUtc: Ts, Kind: AlarmTransitionKind.Raise))); // The OPC UA condition update confirms the transition was processed (ordered before the alerts fan-out). publish.ExpectMsg(TimeSpan.FromSeconds(5)); // Exactly one Alarm item, carrying the same 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(AlarmRawPath); item.E.AlarmName.ShouldBe("temp_hi"); item.E.TransitionKind.ShouldBe("Activated"); item.E.Message.ShouldBe("temperature high"); } /// 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( IDbContextFactory db, DeploymentId deploymentId, ITelemetryLocalHub hub) { 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, telemetryHub: hub)); actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); coordinator.ExpectMsg(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied); publish.ExpectMsg(TimeSpan.FromSeconds(5)); return (actor, publish); } /// Seeds a Sealed v3 deployment with a single Boolean raw tag carrying an alarm object so /// the composer projects it as a Part 9 condition at its RawPath (mirrors DriverHostActorNativeAlarmTests). private static DeploymentId SeedV3AlarmDeployment(IDbContextFactory db, RevisionHash rev) { var artifact = JsonSerializer.SerializeToUtf8Bytes(new { RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } }, DriverInstances = new[] { new { DriverInstanceId = "drv-1", RawFolderId = "rf-plant", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = false }, }, Devices = new[] { new { DeviceId = "drv-1:dev1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" }, }, TagGroups = Array.Empty(), Tags = new[] { new { TagId = "tag-0", DeviceId = "drv-1:dev1", TagGroupId = (string?)null, Name = "temp_hi", DataType = "Boolean", AccessLevel = 0, TagConfig = JsonSerializer.Serialize(new { 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 alarm-subscription handle for building . private sealed class StubAlarmHandle : IAlarmSubscriptionHandle { public string DiagnosticId => "stub-alarm-sub"; } /// Fake hub that records every . is /// unused by the producer seams under test. private sealed class CapturingHub : ITelemetryLocalHub { public List Items { get; } = new(); public void Emit(TelemetryItem item) => Items.Add(item); public ITelemetrySubscription Subscribe(int boundedCapacity) => throw new NotSupportedException(); } }