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.OpcUa;
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.FOCAS;
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 #647 — a FOCAS CNC alarm reaches its Part 9 condition END TO END: the REAL
/// (over a fake CNC client), fed the raw tags exactly as the deployment artifact delivers them, raises into the
/// REAL , whose ForwardNativeAlarm + NativeAlarmProjector must turn it
/// into an Active and then an inactive condition at the tag's RawPath.
///
///
/// The lesson this exists for: OpcUaClient and AbCip native alarms were "routed" — events raised, unit tests
/// green — but never reached a condition, because nothing checked the event against the key the host actually
/// routes on (ConditionId == RawPath) or the field its projector actually reads (Kind). A test that stops at the
/// driver's event cannot see either.
///
public sealed class FocasNativeAlarmEndToEndTests : RuntimeActorTestBase
{
private static readonly NodeId TestNode = NodeId.Parse("focas-alarm-e2e");
private static readonly RevisionHash Rev = RevisionHash.Parse(new string('f', 64));
private const string Host = "focas://10.0.0.5:8193";
private const string DriverId = "drv-focas";
private const string ExpectedRawPath = "Plant/Cnc/m1/servo_alarms";
[Fact]
public async Task A_CNC_servo_alarm_activates_then_clears_the_condition_at_the_RawPath()
{
var db = NewInMemoryDbFactory();
var blob = BuildArtifact();
var deploymentId = Seed(db, blob, Rev);
var (host, publish, _) = SpawnHostAndApply(db, deploymentId);
// The raw tags exactly as the driver receives them: the artifact's merged DriverConfig RawTags array.
var rawTags = RawTagsFor(blob, DriverId);
rawTags.Select(t => t.RawPath).ShouldBe([ExpectedRawPath]);
var cnc = new AlarmOnlyFocasClient();
var driver = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host, DeviceName: "m1")],
RawTags = rawTags,
Probe = new FocasProbeOptions { Enabled = false },
AlarmProjection = new FocasAlarmProjectionOptions { PollInterval = TimeSpan.FromMilliseconds(20) },
}, DriverId, new SingleClientFactory(cnc));
driver.OnAlarmEvent += (_, e) => host.Tell(new DriverInstanceActor.AttributeAlarmPublished(DriverId, e));
await driver.InitializeAsync("{}", CancellationToken.None);
// The host's alarm subscription set is the alarm-bearing raw tags' RawPaths. Called directly, as the
// DriverInstanceActor would through its invoker: the routing is under test, not resilience wrapping.
#pragma warning disable OTOPCUA0001
await driver.SubscribeAlarmsAsync([.. rawTags.Select(t => t.RawPath)], CancellationToken.None);
#pragma warning restore OTOPCUA0001
try
{
cnc.SetAlarms(new FocasActiveAlarm(401, FocasAlarmType.Servo, 1, "SERVO V-READY OFF"));
var active = publish.FishForMessage(_ => true, TimeSpan.FromSeconds(5));
active.AlarmNodeId.ShouldBe(ExpectedRawPath);
active.Realm.ShouldBe(AddressSpaceRealm.Raw);
active.State.Active.ShouldBeTrue("the Raise must project to an ACTIVE condition, not 'keep prior state'");
active.State.Acknowledged.ShouldBeFalse();
active.State.Severity.ShouldBe((ushort)800, "the authored alarm.severity is the condition's severity");
active.State.Message.ShouldStartWith("401 SERVO V-READY OFF");
cnc.SetAlarms();
var cleared = publish.FishForMessage(_ => true, TimeSpan.FromSeconds(5));
cleared.AlarmNodeId.ShouldBe(ExpectedRawPath);
cleared.State.Active.ShouldBeFalse();
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);
}
}
///
/// Gitea #647 review (MAJOR-1) — a deploy that changes ANY of the driver's config respawns it, and the fresh
/// instance has no record of what the server was told. Its first poll re-reads the still-active alarm and
/// sends Raise; the host must treat that as a re-statement: the operator's acknowledgement survives and no
/// second "Activated" alerts row is written. Driven through the real host with a FRESH driver instance.
///
///
/// Re-review R1 — the other half: a DIFFERENT alarm that appeared while the driver was down (here a second
/// servo alarm, so the condition's displayed set changed) is a new statement even though the fresh driver
/// can only say Raise. It must un-acknowledge and write its alerts row.
///
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task A_respawned_driver_re_reading_an_acknowledged_active_alarm_keeps_the_ack(bool newAlarmDuringRespawn)
{
var db = NewInMemoryDbFactory();
var blob = BuildArtifact();
var alerts = CreateTestProbe();
DistributedPubSub.Get(Sys).Mediator.Tell(new Subscribe(ScriptedAlarmHostActor.AlertsTopic, alerts.Ref), alerts.Ref);
alerts.ExpectMsg(TimeSpan.FromSeconds(5));
var (host, publish, coordinator) = SpawnHostAndApply(db, Seed(db, blob, Rev));
var rawTags = RawTagsFor(blob, DriverId);
var cnc = new AlarmOnlyFocasClient();
cnc.SetAlarms(new FocasActiveAlarm(401, FocasAlarmType.Servo, 1, "SERVO V-READY OFF"));
var first = await StartDriverAsync(rawTags, cnc, host);
try
{
publish.FishForMessage(_ => true, TimeSpan.FromSeconds(5))
.State.Acknowledged.ShouldBeFalse();
alerts.ExpectMsg(TimeSpan.FromSeconds(5));
}
finally
{
await first.ShutdownAsync(CancellationToken.None);
}
// The operator acknowledges, then a deploy changes the driver's config => stop + respawn.
host.Tell(new DriverHostActor.RouteNativeAlarmAck(ExpectedRawPath, "seen", "op"));
var revB = RevisionHash.Parse(new string('e', 64));
host.Tell(new DispatchDeployment(Seed(db, blob, revB), revB, CorrelationId.NewId()));
coordinator.FishForMessage(a => a.Outcome == ApplyAckOutcome.Applied, TimeSpan.FromSeconds(5));
publish.FishForMessage(_ => true, TimeSpan.FromSeconds(5));
if (newAlarmDuringRespawn)
cnc.SetAlarms(
new FocasActiveAlarm(401, FocasAlarmType.Servo, 1, "SERVO V-READY OFF"),
new FocasActiveAlarm(410, FocasAlarmType.Servo, 2, "SERVO EXCESS ERROR"));
var respawned = await StartDriverAsync(rawTags, cnc, host);
try
{
var restated = publish.FishForMessage(_ => true, TimeSpan.FromSeconds(5));
restated.AlarmNodeId.ShouldBe(ExpectedRawPath);
restated.State.Active.ShouldBeTrue();
if (newAlarmDuringRespawn)
{
restated.State.Acknowledged.ShouldBeFalse("an alarm the operator has not seen must not inherit the old ack");
alerts.ExpectMsg(TimeSpan.FromSeconds(5));
}
else
{
restated.State.Acknowledged.ShouldBeTrue("a respawned driver's re-statement must not un-acknowledge");
alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
}
finally
{
await respawned.ShutdownAsync(CancellationToken.None);
}
}
private static async Task StartDriverAsync(List rawTags, IFocasClient cnc, IActorRef host)
{
var driver = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host, DeviceName: "m1")],
RawTags = rawTags,
Probe = new FocasProbeOptions { Enabled = false },
AlarmProjection = new FocasAlarmProjectionOptions { PollInterval = TimeSpan.FromMilliseconds(20) },
}, DriverId, new SingleClientFactory(cnc));
driver.OnAlarmEvent += (_, e) => host.Tell(new DriverInstanceActor.AttributeAlarmPublished(DriverId, e));
await driver.InitializeAsync("{}", CancellationToken.None);
#pragma warning disable OTOPCUA0001 // the routing is under test, not resilience wrapping
await driver.SubscribeAlarmsAsync([.. rawTags.Select(t => t.RawPath)], CancellationToken.None);
#pragma warning restore OTOPCUA0001
return driver;
}
/// The seeded artifact's FOCAS driver raw tags, decoded through the production artifact reader.
private static List RawTagsFor(byte[] blob, string driverInstanceId)
{
using var artifact = DeploymentArtifact.Open(blob);
var spec = artifact.ParseDriverInstances().Single(s => s.DriverInstanceId == driverInstanceId);
using var doc = JsonDocument.Parse(spec.DriverConfig);
return JsonSerializer.Deserialize>(
doc.RootElement.GetProperty("RawTags").GetRawText(),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
}
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 = "Cnc", DriverType = "FOCAS", DriverConfig = "{}", ClusterId = "c1", Enabled = false },
},
Devices = new[]
{
new { DeviceId = "dev-m1", DriverInstanceId = DriverId, Name = "m1", DeviceConfig = JsonSerializer.Serialize(new { HostAddress = Host }) },
},
TagGroups = Array.Empty