83d8de303c
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
229 lines
11 KiB
C#
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() { }
|
|
}
|
|
}
|
|
}
|