using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading.Channels;
using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe;
using Akka.TestKit;
using Google.Protobuf.WellKnownTypes;
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.Galaxy;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
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;
using LocalizedText = Opc.Ua.LocalizedText;
using Proto = ZB.MOM.WW.MxGateway.Contracts.Proto;
using UaNodeId = Opc.Ua.NodeId;
using Variant = Opc.Ua.Variant;
using VariantCollection = Opc.Ua.VariantCollection;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
///
/// Gitea #656 — native-alarm RE-STATEMENTS end to end: the REAL Galaxy driver (over the REAL
/// reading a scripted gateway stream) and the REAL OpcUaClient driver raise
/// into the REAL , and the test reads what its ForwardNativeAlarm +
/// NativeAlarmProjector turned them into: the Part 9 condition updates and the /alerts rows.
///
///
/// Every "no row" assertion is followed by a transition that MUST write one — an absence check on its own passes
/// against a host that never publishes anything.
///
public sealed class NativeAlarmRestatementEndToEndTests : RuntimeActorTestBase
{
private static readonly NodeId TestNode = NodeId.Parse("alarm-restatement-e2e");
private static readonly RevisionHash Rev = RevisionHash.Parse(new string('a', 64));
private static readonly TimeSpan Wait = TimeSpan.FromSeconds(5);
private static readonly TimeSpan Quiet = TimeSpan.FromMilliseconds(600);
// ---------------------------------------------------------------------------------------------------------
// Galaxy
// ---------------------------------------------------------------------------------------------------------
private const string GalaxyDriverId = "drv-galaxy";
private const string GalaxyAlarmRef = "Tank01.Level.HiHi";
private const string GalaxyRawPath = "Plant/Galaxy/gw/tank01_hihi";
///
/// #656 claim 1 — a cold host receives an ActiveAcked snapshot entry (an alarm active AND acknowledged in
/// AVEVA before this process started). Pre-fix the feed sent Acknowledge, which the projector applied to its
/// inactive default: the condition was INACTIVE+acked — invisible to clients — with an "Acknowledged" row for
/// an alarm the server showed as inactive. Now: ONE Active+acked update, "Activated" (at the original raise
/// time, no operator comment) then "Acknowledged" (carrying the comment).
///
[Fact]
public async Task Galaxy_cold_host_ActiveAcked_snapshot_projects_active_and_acked_with_both_rows()
{
var rig = await GalaxyRig.StartAsync(this);
try
{
var raisedAt = new DateTime(2026, 10, 8, 9, 0, 0, DateTimeKind.Utc);
var ackedAt = raisedAt.AddMinutes(2);
rig.Send(Snapshot(Proto.AlarmConditionState.ActiveAcked, raisedAt, ackedAt, "alice", "investigating"));
var update = NextAlarmUpdate(rig.Publish);
update.AlarmNodeId.ShouldBe(GalaxyRawPath);
update.State.Active.ShouldBeTrue("an acked active alarm must project ACTIVE, not keep the cold default");
update.State.Acknowledged.ShouldBeTrue();
var activated = rig.Alerts.ExpectMsg(Wait);
activated.TransitionKind.ShouldBe("Activated");
activated.Comment.ShouldBeNull("the operator comment belongs to the acknowledgement");
activated.TimestampUtc.ShouldBe(raisedAt);
var acked = rig.Alerts.ExpectMsg(Wait);
acked.TransitionKind.ShouldBe("Acknowledged");
acked.Comment.ShouldBe("investigating");
rig.Alerts.ExpectNoMsg(Quiet);
// Positive control: a live Clear MUST write its row — and the NEXT condition update is the clear, so no
// unacked intermediate update sat between the snapshot's projection and it.
rig.Send(Live(Proto.AlarmTransitionKind.Clear, ackedAt.AddMinutes(1)));
var cleared = NextAlarmUpdate(rig.Publish);
cleared.State.Active.ShouldBeFalse();
cleared.State.Acknowledged.ShouldBeTrue();
rig.Alerts.ExpectMsg(Wait).TransitionKind.ShouldBe("Cleared");
}
finally
{
await rig.DisposeAsync();
}
}
///
/// #656 claim 2 — every gateway reconnect replays the active-alarm snapshot. Pre-fix an unacked active entry
/// (a Retrigger) wrote a second "Activated" row on EVERY reconnect, for a condition whose node did not change
/// (so clients saw no event: history and the node disagreed). Likewise an Inactive entry wrote a duplicate
/// "Cleared". Now a replay re-projects the condition but writes no row; a live transition still does.
///
[Fact]
public async Task Galaxy_reconnect_snapshot_replay_writes_no_duplicate_rows()
{
var rig = await GalaxyRig.StartAsync(this);
try
{
var raisedAt = new DateTime(2026, 10, 8, 9, 0, 0, DateTimeKind.Utc);
rig.Send(Snapshot(Proto.AlarmConditionState.Active, raisedAt, raisedAt));
NextAlarmUpdate(rig.Publish).State.Active.ShouldBeTrue();
rig.Alerts.ExpectMsg(Wait).TransitionKind.ShouldBe("Activated",
"the first statement on a cold host is a real activation");
// The gateway stream faults; the feed reopens and the gateway replays the same snapshot.
rig.Fault();
rig.Send(Snapshot(Proto.AlarmConditionState.Active, raisedAt, raisedAt));
var replayed = NextAlarmUpdate(rig.Publish);
replayed.State.Active.ShouldBeTrue();
replayed.State.Acknowledged.ShouldBeFalse();
rig.Alerts.ExpectNoMsg(Quiet);
// Positive control: a live Clear MUST write its row.
rig.Send(Live(Proto.AlarmTransitionKind.Clear, raisedAt.AddMinutes(1)));
NextAlarmUpdate(rig.Publish).State.Active.ShouldBeFalse();
rig.Alerts.ExpectMsg(Wait).TransitionKind.ShouldBe("Cleared");
// Reconnect again; the snapshot now carries the alarm as Inactive — a re-stated Clear of an inactive
// condition, no row.
rig.Fault();
rig.Send(Snapshot(Proto.AlarmConditionState.Inactive, raisedAt, raisedAt.AddMinutes(1)));
NextAlarmUpdate(rig.Publish).State.Active.ShouldBeFalse();
rig.Alerts.ExpectNoMsg(Quiet);
// Positive control: a LIVE Retrigger (a genuine MXAccess re-occurrence) is never a re-statement.
rig.Send(Live(Proto.AlarmTransitionKind.Retrigger, raisedAt.AddMinutes(3)));
NextAlarmUpdate(rig.Publish).State.Active.ShouldBeTrue();
rig.Alerts.ExpectMsg(Wait).TransitionKind.ShouldBe("Activated");
}
finally
{
await rig.DisposeAsync();
}
}
private static Proto.AlarmFeedMessage Snapshot(
Proto.AlarmConditionState state, DateTime raisedAt, DateTime lastTransition, string user = "", string comment = "")
=> new()
{
ActiveAlarm = new Proto.ActiveAlarmSnapshot
{
AlarmFullReference = GalaxyAlarmRef,
SourceObjectReference = "Tank01",
AlarmTypeName = "AnalogLimitAlarm.HiHi",
Severity = 750,
CurrentState = state,
Category = "Process",
Description = "Tank 01 high-high level",
OriginalRaiseTimestamp = Timestamp.FromDateTime(raisedAt),
LastTransitionTimestamp = Timestamp.FromDateTime(lastTransition),
OperatorUser = user,
OperatorComment = comment,
},
};
private static Proto.AlarmFeedMessage Live(Proto.AlarmTransitionKind kind, DateTime at)
=> new()
{
Transition = new Proto.OnAlarmTransitionEvent
{
AlarmFullReference = GalaxyAlarmRef,
SourceObjectReference = "Tank01",
AlarmTypeName = "AnalogLimitAlarm.HiHi",
TransitionKind = kind,
Severity = 750,
Category = "Process",
Description = "Tank 01 high-high level",
TransitionTimestamp = Timestamp.FromDateTime(at),
},
};
/// The real Galaxy driver over the real alarm feed, reading a scripted gateway stream.
private sealed class GalaxyRig : IAsyncDisposable
{
private readonly Channel