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