Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/AbCipAlmaNativeAlarmEndToEndTests.cs
T
Joseph Doherty 83d8de303c fix(abcip): ALMA review round 1 + ALMD polls InAlarm (not InFaulted)
M1 drop-out is a no-row Unspecified+IsRestatement update (projector case); M2 ack judged over every declared
limit; L1 ActiveAcknowledged for an already-acked new limit; L2 Bad Acked/severity carried/skipped; L3 host
end-to-end test; L4 BOOL member-by-member tests + mixed ALMD/ALMA; L5 detection requires no plain Acked.
ALMD active bit is InAlarm per Rockwell's ALMD reference; InFaulted is input health.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-09 04:22:45 -04:00

229 lines
11 KiB
C#

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;
/// <summary>
/// Gitea #611 item 1 (hw4 review L3) — an AbCip ALMA reaches its Part 9 condition and its <c>/alerts</c> rows END
/// TO END: the REAL <see cref="AbCipDriver"/> (polling a fake controller, ab_server/Z35162 have no ALMA) raises into
/// the REAL <see cref="DriverHostActor"/>, and the test reads what <c>ForwardNativeAlarm</c> +
/// <c>NativeAlarmProjector</c> 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.
/// </summary>
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"];
/// <summary>
/// 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.
/// </summary>
[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<AlarmTransitionEvent>(Wait);
private static OpcUaPublishActor.AlarmStateUpdate NextUpdate(TestProbe publish) =>
publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(_ => true, Wait);
private TestProbe SubscribeAlerts()
{
var alerts = CreateTestProbe();
DistributedPubSub.Get(Sys).Mediator.Tell(new Subscribe(ScriptedAlarmHostActor.AlertsTopic, alerts.Ref), alerts.Ref);
alerts.ExpectMsg<SubscribeAck>(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<object>(),
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<OtOpcUaConfigDbContext> 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<OtOpcUaConfigDbContext> db, DeploymentId deploymentId)
{
var coordinator = CreateTestProbe();
var publish = this.CreateCleanOutcomePublishProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref,
localRoles: new HashSet<string> { "driver" },
dependencyMux: CreateTestProbe().Ref,
opcUaPublishActor: publish.Ref,
virtualTagEvaluator: NullVirtualTagEvaluator.Instance,
virtualTagHostOverride: CreateTestProbe().Ref));
actor.Tell(new DispatchDeployment(deploymentId, Rev, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(Wait).Outcome.ShouldBe(ApplyAckOutcome.Applied);
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(Wait);
return (actor, publish);
}
/// <summary>A fake controller: every member runtime answers from one shared value table, keyed by member name.</summary>
private sealed class FakePlc : IAbCipTagFactory
{
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, object?> _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() { }
}
}
}