using System.Text.Json; using Akka.Actor; using Akka.Cluster.Tools.PublishSubscribe; using Akka.TestKit; 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.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.Driver.AbCip; 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.Tests.Harness; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; /// /// Gitea #611 item 1 (hw4 review L3) — an AbCip ALMA reaches its Part 9 condition and its /alerts rows END /// TO END: the REAL (polling a fake controller, ab_server/Z35162 have no ALMA) raises into /// the REAL , and the test reads what ForwardNativeAlarm + /// NativeAlarmProjector turned the projection's events into. The row level is where a drop-out's false /// "Activated" and a surviving limit's false "Acknowledged" show — the driver's own events look fine. /// public sealed class AbCipAlmaNativeAlarmEndToEndTests : RuntimeActorTestBase { private static readonly NodeId TestNode = NodeId.Parse("abcip-alma-e2e"); private static readonly RevisionHash Rev = RevisionHash.Parse(new string('c', 64)); private static readonly TimeSpan Wait = TimeSpan.FromSeconds(5); private static readonly TimeSpan Quiet = TimeSpan.FromMilliseconds(600); private const string DriverId = "drv-abcip"; private const string Device = "ab://10.0.0.5/1,0"; private const string TagPath = "HighPress"; private const string RawPath = "Plant/AbCip/plc/hp"; private static readonly string[] Bits = ["HHInAlarm", "HInAlarm", "LInAlarm", "LLInAlarm", "HHAcked", "HAcked", "LAcked", "LLAcked"]; private static readonly string[] Sevs = ["HHSeverity", "HSeverity", "LSeverity", "LLSeverity"]; /// /// raise (H) → escalate (HH enters: Retrigger) → HH drops out (no row, node text moves to H) → the PLC acks /// everything (ONE "Acknowledged") → HH excursion unacked then gone (still unacked, no false ack) → clear. /// [Fact] public async Task An_ALMA_walks_raise_escalate_dropout_ack_clear_with_the_right_rows() { var db = NewInMemoryDbFactory(); var alerts = SubscribeAlerts(); var (host, publish) = SpawnHostAndApply(db, Seed(db, BuildArtifact(), Rev)); var plc = new FakePlc(); foreach (var b in Bits) plc.Set(b, false); plc.Set("HHSeverity", 900); plc.Set("HSeverity", 600); plc.Set("LSeverity", 300); plc.Set("LLSeverity", 800); var drv = new AbCipDriver( new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], RawTags = [new RawTagEntry(RawPath, TagConfig(), false, Device)], EnableAlarmProjection = true, AlarmPollInterval = TimeSpan.FromMilliseconds(20), }, DriverId, plc); try { await drv.InitializeAsync("{}", CancellationToken.None); drv.OnAlarmEvent += (_, e) => host.Tell(new DriverInstanceActor.AttributeAlarmPublished(DriverId, e)); #pragma warning disable OTOPCUA0001 // the routing is under test, not resilience wrapping await drv.SubscribeAlarmsAsync([RawPath], CancellationToken.None); #pragma warning restore OTOPCUA0001 // Raise: H. plc.Set("HInAlarm", true); var raised = NextUpdate(publish); raised.AlarmNodeId.ShouldBe(RawPath); raised.State.Active.ShouldBeTrue(); raised.State.Acknowledged.ShouldBeFalse(); raised.State.Message.ShouldContain("H alarm"); Row(alerts).TransitionKind.ShouldBe("Activated"); // Escalate: HH enters while H stands — a new unacknowledged occurrence. plc.Set("HHInAlarm", true); var esc = NextUpdate(publish); esc.State.Active.ShouldBeTrue(); esc.State.Acknowledged.ShouldBeFalse(); esc.State.Message.ShouldContain("HH alarm"); Row(alerts).TransitionKind.ShouldBe("Activated"); // HH drops out, H stands: text/severity move to H, but nothing new happened — NO row. plc.Set("HHInAlarm", false); var drop = NextUpdate(publish); drop.State.Active.ShouldBeTrue(); drop.State.Acknowledged.ShouldBeFalse(); drop.State.Message.ShouldContain("H alarm"); drop.State.Message.ShouldNotContain("HH"); drop.State.Severity.ShouldBe((ushort)600); alerts.ExpectNoMsg(Quiet); // The PLC acknowledges every declared limit — ONE "Acknowledged" row. foreach (var a in new[] { "HHAcked", "HAcked", "LAcked", "LLAcked" }) plc.Set(a, true); NextUpdate(publish).State.Acknowledged.ShouldBeTrue(); Row(alerts).TransitionKind.ShouldBe("Acknowledged"); // An HH excursion nobody acknowledges (HHAcked drops, HH enters then leaves): the surviving, acked H // must not turn into a device "Acknowledged" for it. plc.Set("HHAcked", false); plc.Set("HHInAlarm", true); NextUpdate(publish).State.Acknowledged.ShouldBeFalse(); Row(alerts).TransitionKind.ShouldBe("Activated"); plc.Set("HHInAlarm", false); var back = NextUpdate(publish); back.State.Acknowledged.ShouldBeFalse("the unacknowledged HH excursion keeps the condition unacknowledged"); alerts.ExpectNoMsg(Quiet); // Positive control: the clear MUST write its row (the absence checks above would pass on a dead host). plc.Set("HInAlarm", false); var cleared = NextUpdate(publish); cleared.State.Active.ShouldBeFalse(); Row(alerts).TransitionKind.ShouldBe("Cleared"); } finally { await drv.ShutdownAsync(CancellationToken.None); drv.Dispose(); } } private AlarmTransitionEvent Row(TestProbe alerts) => alerts.ExpectMsg(Wait); private static OpcUaPublishActor.AlarmStateUpdate NextUpdate(TestProbe publish) => publish.FishForMessage(_ => true, Wait); private TestProbe SubscribeAlerts() { var alerts = CreateTestProbe(); DistributedPubSub.Get(Sys).Mediator.Tell(new Subscribe(ScriptedAlarmHostActor.AlertsTopic, alerts.Ref), alerts.Ref); alerts.ExpectMsg(Wait); return alerts; } private static string TagConfig() { var members = Bits.Select(n => (object)new { name = n, dataType = "Bool" }) .Concat(Sevs.Select(n => (object)new { name = n, dataType = "DInt" })).ToArray(); return JsonSerializer.Serialize(new { tagPath = TagPath, dataType = "Structure", members, alarm = new { alarmType = "AlarmCondition", severity = 800 }, }); } private static byte[] BuildArtifact() => JsonSerializer.SerializeToUtf8Bytes(new { RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } }, DriverInstances = new[] { // Enabled=false: the host must not spawn a real wire driver — the test drives its own instance. new { DriverInstanceId = DriverId, RawFolderId = "rf-plant", Name = "AbCip", DriverType = DriverTypeNames.AbCip, DriverConfig = "{}", ClusterId = "c1", Enabled = false }, }, Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = DriverId, Name = "plc", DeviceConfig = "{}" } }, TagGroups = Array.Empty(), Tags = new[] { new { TagId = "tag-alarm", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "hp", DataType = "Boolean", AccessLevel = 0, TagConfig = TagConfig() }, }, }); private static DeploymentId Seed(IDbContextFactory db, byte[] blob, RevisionHash rev) { 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 = blob, }); ctx.SaveChanges(); return id; } private (IActorRef Actor, TestProbe Publish) SpawnHostAndApply(IDbContextFactory db, DeploymentId deploymentId) { var coordinator = CreateTestProbe(); var publish = this.CreateCleanOutcomePublishProbe(); var actor = Sys.ActorOf(DriverHostActor.Props( db, TestNode, coordinator.Ref, localRoles: new HashSet { "driver" }, dependencyMux: CreateTestProbe().Ref, opcUaPublishActor: publish.Ref, virtualTagEvaluator: NullVirtualTagEvaluator.Instance, virtualTagHostOverride: CreateTestProbe().Ref)); actor.Tell(new DispatchDeployment(deploymentId, Rev, CorrelationId.NewId())); coordinator.ExpectMsg(Wait).Outcome.ShouldBe(ApplyAckOutcome.Applied); publish.ExpectMsg(Wait); return (actor, publish); } /// A fake controller: every member runtime answers from one shared value table, keyed by member name. private sealed class FakePlc : IAbCipTagFactory { private readonly System.Collections.Concurrent.ConcurrentDictionary _values = new(); public void Set(string member, object value) => _values[member] = value; public IAbCipTagRuntime Create(AbCipTagCreateParams p) => new Runtime(this, p.TagName[(p.TagName.LastIndexOf('.') + 1)..]); private sealed class Runtime(FakePlc plc, string member) : IAbCipTagRuntime { public Task InitializeAsync(CancellationToken ct) => Task.CompletedTask; public Task ReadAsync(CancellationToken ct) => Task.CompletedTask; public Task WriteAsync(CancellationToken ct) => Task.CompletedTask; public int GetStatus() => 0; public object? DecodeValue(AbCipDataType type, int? bitIndex) => plc._values.GetValueOrDefault(member); public object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex) => null; public object? DecodeArray(AbCipDataType type, int count) => null; public void EncodeValue(AbCipDataType type, int? bitIndex, object? value) { } public void Dispose() { } } } }