using System.Collections.Concurrent; using System.Reflection; using Opc.Ua; using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms; using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters; /// /// Review 2026-10-21 02/L3: the per-condition (active, acked) memory behind /// DeriveKind must not grow for the life of a session. A condition that returns to /// inactive + acknowledged is indistinguishable from one never seen (the default prior state), so /// its key is dropped — otherwise a server whose condition names carry a per-occurrence component /// (batch, lot, counter) adds a key per occurrence until the next reconnect. /// public class RealOpcUaAlarmLastStateTests { private const string Handle = "h1"; private static readonly MethodInfo Handler = typeof(RealOpcUaClient).GetMethod("HandleAlarmEvent", BindingFlags.Instance | BindingFlags.NonPublic)!; private static Dictionary SeedLastState(RealOpcUaClient client) { var field = typeof(RealOpcUaClient).GetField("_alarmLastState", BindingFlags.Instance | BindingFlags.NonPublic)!; var map = (ConcurrentDictionary>)field.GetValue(client)!; var perHandle = new Dictionary(StringComparer.Ordinal); map[Handle] = perHandle; return perHandle; } private static void Emit(RealOpcUaClient client, string condition, bool active, bool acked, List sink) { var fields = new VariantCollection { new(ObjectTypeIds.AlarmConditionType), // 0 EventType new(new NodeId("Tank01", 2)), // 1 SourceNode new("Tank01"), // 2 SourceName new(DateTime.UtcNow), // 3 Time new(new LocalizedText("msg")), // 4 Message new((ushort)500), // 5 Severity new(active), // 6 ActiveState/Id new(acked), // 7 AckedState/Id Variant.Null, // 8 ConfirmedState/Id new(false), // 9 SuppressedOrShelved Variant.Null, // 10 ShelvingState new(condition), // 11 ConditionName }; Handler.Invoke(client, [Handle, "ns=2;s=Tank01", new EventFieldList { EventFields = fields }, (Action)sink.Add]); } /// A per-occurrence condition that clears and is acknowledged leaves no key behind. [Fact] public void ReturnToNormal_DropsTheConditionKey() { var client = new RealOpcUaClient(); var lastState = SeedLastState(client); var sink = new List(); for (var lot = 1; lot <= 50; lot++) { Emit(client, $"Hi.Lot{lot}", active: true, acked: false, sink); Emit(client, $"Hi.Lot{lot}", active: false, acked: true, sink); } Assert.Empty(lastState); Assert.Equal(100, sink.Count); } /// /// Dropping the key does not change the transition kinds derived for a condition that comes /// back — the dropped state equals the default prior state. /// [Fact] public void ReRaiseAfterReturnToNormal_StillDerivesARaise() { var client = new RealOpcUaClient(); var lastState = SeedLastState(client); var sink = new List(); Emit(client, "Hi", active: true, acked: false, sink); Emit(client, "Hi", active: false, acked: true, sink); Emit(client, "Hi", active: true, acked: false, sink); Assert.Equal(sink[0].Kind, sink[2].Kind); Assert.Single(lastState); // the re-raised condition is tracked again } }