diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs index 82e1965f..fa6e8e34 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs @@ -99,8 +99,14 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers // routed to subscribers (NativeAlarmActors) by source-object reference. /// sourceReference → set of subscriber actor refs (NativeAlarmActors), for routing + ref-count. private readonly Dictionary> _alarmSourceSubscribers = new(); - /// sourceReference → optional condition filter (first subscriber wins). + /// sourceReference → raw condition filter string passed to the adapter (first subscriber wins). private readonly Dictionary _alarmSourceFilter = new(); + /// + /// sourceReference → parsed condition-type predicate (M2.4 / #8). The authoritative + /// client-side gate in ; applies uniformly + /// across OPC UA and the gateway-wide MxGateway feed. + /// + private readonly Dictionary _alarmSourceFilterPredicate = new(); /// sourceReference → adapter alarm subscription id. private readonly Dictionary _alarmSubscriptionIds = new(); /// sourceReferences whose adapter SubscribeAlarmsAsync is currently in flight. @@ -1480,6 +1486,9 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers } subs.Add(subscriber); _alarmSourceFilter[request.SourceReference] = request.ConditionFilter; + // Parse the type-name filter once; this is the authoritative client-side + // gate consulted on every routed transition (M2.4 / #8). + _alarmSourceFilterPredicate[request.SourceReference] = AlarmConditionFilter.Parse(request.ConditionFilter); // If the adapter feed for this source is already (being) established, the // existing subscription serves the new subscriber too. @@ -1546,6 +1555,14 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers if (!match) continue; + // M2.4 (#8): authoritative client-side condition-type gate. Applied + // per matched source because two sources may share a prefix yet carry + // different filters. Empty filter = allow all (historical behaviour); + // framing sentinels (SnapshotComplete) are never dropped. + if (_alarmSourceFilterPredicate.TryGetValue(sourceRef, out var predicate) && + !predicate.IsAllowed(transition)) + continue; + foreach (var sub in subs) { if (notified.Add(sub)) @@ -1566,6 +1583,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers // No subscribers remain for this source — tear down the adapter feed. _alarmSourceSubscribers.Remove(request.SourceReference); _alarmSourceFilter.Remove(request.SourceReference); + _alarmSourceFilterPredicate.Remove(request.SourceReference); if (_alarmSubscriptionIds.Remove(request.SourceReference, out var subId) && _adapter is IAlarmSubscribableConnection alarmable) { diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayDataConnection.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayDataConnection.cs index a1e83886..2e6ec9d4 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayDataConnection.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayDataConnection.cs @@ -163,7 +163,11 @@ public class MxGatewayDataConnection : IDataConnection, IBrowsableDataConnection _alarmCts = new CancellationTokenSource(); var token = _alarmCts.Token; var client = _client!; - // Gateway-wide feed (null prefix); the actor filters per source reference. + // Gateway-wide feed (null prefix). The MxGateway has no server-side + // condition filter, so conditionFilter is intentionally NOT forwarded + // here: the DataConnectionActor applies it as the authoritative + // client-side gate per source reference AND per condition type + // (M2.4 / #8 — AlarmConditionFilter), uniform with the OPC UA path. _ = Task.Run(() => client.RunAlarmStreamAsync(null, t => callback(t), token), token); } } diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs index d24dfa85..d33b3ebd 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs @@ -258,7 +258,9 @@ public class RealOpcUaClient : IOpcUaClient MonitoringMode = MonitoringMode.Reporting, SamplingInterval = 0, QueueSize = 1000, - Filter = BuildAlarmEventFilter() + // Server-side WhereClause is a bandwidth optimisation only — the + // authoritative condition-type gate lives in DataConnectionActor (M2.4 / #8). + Filter = BuildAlarmEventFilter(AlarmConditionFilter.Parse(conditionFilter)) }; item.Notification += (_, e) => @@ -289,10 +291,56 @@ public class RealOpcUaClient : IOpcUaClient } /// - /// Builds the event filter selecting the base event fields plus the - /// AlarmConditionType / AcknowledgeableConditionType state sub-variables we mirror. + /// Maps the standard OPC UA Alarms & Conditions type names (case-insensitive) + /// to their well-known NodeIds, for building the + /// optional server-side WhereClause (M2.4 / #8). Only standard types appear + /// here; vendor/custom type names cannot be mapped without browsing the server + /// type tree, so they are handled by the client-side gate alone. /// - private static EventFilter BuildAlarmEventFilter() + private static readonly IReadOnlyDictionary KnownConditionTypeIds = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ConditionType"] = ObjectTypeIds.ConditionType, + ["AcknowledgeableConditionType"] = ObjectTypeIds.AcknowledgeableConditionType, + ["AlarmConditionType"] = ObjectTypeIds.AlarmConditionType, + ["LimitAlarmType"] = ObjectTypeIds.LimitAlarmType, + ["ExclusiveLimitAlarmType"] = ObjectTypeIds.ExclusiveLimitAlarmType, + ["NonExclusiveLimitAlarmType"] = ObjectTypeIds.NonExclusiveLimitAlarmType, + ["ExclusiveLevelAlarmType"] = ObjectTypeIds.ExclusiveLevelAlarmType, + ["NonExclusiveLevelAlarmType"] = ObjectTypeIds.NonExclusiveLevelAlarmType, + ["ExclusiveDeviationAlarmType"] = ObjectTypeIds.ExclusiveDeviationAlarmType, + ["NonExclusiveDeviationAlarmType"] = ObjectTypeIds.NonExclusiveDeviationAlarmType, + ["ExclusiveRateOfChangeAlarmType"] = ObjectTypeIds.ExclusiveRateOfChangeAlarmType, + ["NonExclusiveRateOfChangeAlarmType"] = ObjectTypeIds.NonExclusiveRateOfChangeAlarmType, + ["DiscreteAlarmType"] = ObjectTypeIds.DiscreteAlarmType, + ["OffNormalAlarmType"] = ObjectTypeIds.OffNormalAlarmType, + ["SystemOffNormalAlarmType"] = ObjectTypeIds.SystemOffNormalAlarmType, + ["TripAlarmType"] = ObjectTypeIds.TripAlarmType, + ["DiscrepancyAlarmType"] = ObjectTypeIds.DiscrepancyAlarmType, + ["InstrumentDiagnosticAlarmType"] = ObjectTypeIds.InstrumentDiagnosticAlarmType, + ["SystemDiagnosticAlarmType"] = ObjectTypeIds.SystemDiagnosticAlarmType, + ["CertificateExpirationAlarmType"] = ObjectTypeIds.CertificateExpirationAlarmType, + }; + + /// + /// Builds the event filter selecting the base event fields plus the + /// AlarmConditionType / AcknowledgeableConditionType state sub-variables we mirror, + /// and — when is non-empty and every requested + /// type maps to a standard A&C type — a server-side + /// WhereClause (OfType, OR'd) as a bandwidth optimisation (M2.4 / #8). + /// + /// + /// Conservative by design: if any requested type name cannot be mapped to + /// a standard NodeId, the WhereClause is omitted entirely + /// rather than partially applied — a partial server-side filter would silently drop + /// the unmapped types' events, and the server cannot send what it filtered out. The + /// client-side gate in DataConnectionActor enforces the full filter regardless, so + /// omitting the WhereClause only forgoes the bandwidth saving, never correctness. + /// + /// + /// The parsed condition-type filter (allow-all when empty). + /// The configured . + internal static EventFilter BuildAlarmEventFilter(AlarmConditionFilter conditionFilter) { var filter = new EventFilter(); foreach (var name in AlarmStateFields) @@ -306,9 +354,48 @@ public class RealOpcUaClient : IOpcUaClient filter.SelectClauses.Add(SelectField(ObjectTypeIds.AlarmConditionType, "ShelvingState", "CurrentState"));// 10 filter.SelectClauses.Add(SelectField(ObjectTypeIds.ConditionType, "ConditionName")); // 11 filter.SelectClauses.Add(SelectField(ObjectTypeIds.ConditionType, "Comment")); // 12 + + ApplyServerSideTypeWhereClause(filter, conditionFilter); return filter; } + /// + /// Attaches an OfType(-OR'd) WhereClause to when every + /// requested condition type maps to a standard A&C type NodeId; otherwise leaves + /// the WhereClause empty (see rationale). + /// + private static void ApplyServerSideTypeWhereClause(EventFilter filter, AlarmConditionFilter conditionFilter) + { + if (conditionFilter.IsEmpty) + return; + + var typeIds = new List(); + foreach (var name in conditionFilter.Names) + { + if (!KnownConditionTypeIds.TryGetValue(name, out var id)) + return; // unmapped type → omit the WhereClause entirely (client gate covers it) + typeIds.Add(id); + } + + if (typeIds.Count == 0) + return; + + var where = filter.WhereClause; + if (typeIds.Count == 1) + { + where.Push(FilterOperator.OfType, typeIds[0]); + return; + } + + // OR together each OfType element so an event of ANY listed type passes. + var element = where.Push(FilterOperator.OfType, typeIds[0]); + for (var i = 1; i < typeIds.Count; i++) + { + var next = where.Push(FilterOperator.OfType, typeIds[i]); + element = where.Push(FilterOperator.Or, element, next); + } + } + private static SimpleAttributeOperand SelectField(NodeId typeDefinitionId, params string[] browse) { var path = new QualifiedNameCollection(); diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/AlarmConditionFilter.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/AlarmConditionFilter.cs new file mode 100644 index 00000000..ecf9e801 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/AlarmConditionFilter.cs @@ -0,0 +1,78 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; + +namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer; + +/// +/// Parsed native-alarm condition filter (M2.4 / #8). +/// +/// +/// A source's conditionFilter is a comma-separated, case-insensitive list +/// of alarm/condition type names, matched against +/// . A null, blank, or +/// all-empty list means "mirror every condition" (the historical behaviour), +/// represented here by . +/// +/// +/// +/// This is the authoritative client-side gate consulted in the +/// DataConnectionActor routing path, so it applies uniformly across OPC UA +/// (whose server-side WhereClause is only a bandwidth optimisation) and the +/// MxGateway (whose single gateway-wide feed has no server-side filter at all). +/// Parse once at subscribe time; is the hot-path check. +/// +/// +public sealed class AlarmConditionFilter +{ + /// The shared allow-all instance (empty filter set). + public static readonly AlarmConditionFilter AllowAll = new(new HashSet(StringComparer.OrdinalIgnoreCase)); + + private readonly HashSet _names; + + private AlarmConditionFilter(HashSet names) => _names = names; + + /// true when no type names are configured — every condition is allowed. + public bool IsEmpty => _names.Count == 0; + + /// The normalized (trimmed) type names, for the OPC UA server-side WhereClause optimisation. + public IReadOnlyCollection Names => _names; + + /// + /// Parses a raw conditionFilter string into a normalized, case-insensitive + /// type-name set. null/blank/all-empty input yields an empty (allow-all) filter. + /// + /// The raw comma-separated filter string, or null. + /// A parsed ; never null. + public static AlarmConditionFilter Parse(string? conditionFilter) + { + if (string.IsNullOrWhiteSpace(conditionFilter)) + return AllowAll; + + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var raw in conditionFilter.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + names.Add(raw); + + return names.Count == 0 ? AllowAll : new AlarmConditionFilter(names); + } + + /// + /// Returns true when should be delivered: + /// the filter is empty (allow all), the transition is a framing sentinel + /// (, which carries no condition + /// type and must never be swallowed or the snapshot swap never completes), or its + /// is in the configured set. + /// + /// The protocol-neutral transition to test. + /// true to deliver the transition; false to drop it. + public bool IsAllowed(NativeAlarmTransition transition) + { + if (_names.Count == 0) + return true; + + // SnapshotComplete is pure framing (no condition payload) — never filter it. + if (transition.Kind == AlarmTransitionKind.SnapshotComplete) + return true; + + return _names.Contains(transition.AlarmTypeName); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.csproj b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.csproj index 7a786ed4..751f2fb1 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.csproj +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.csproj @@ -19,6 +19,13 @@ + + + + + diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientAlarmFilterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientAlarmFilterTests.cs new file mode 100644 index 00000000..ba93e822 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientAlarmFilterTests.cs @@ -0,0 +1,76 @@ +using Opc.Ua; +using ZB.MOM.WW.ScadaBridge.DataConnectionLayer; +using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; + +namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters; + +/// +/// M2.4 (#8): the OPC UA EventFilter gains a server-side +/// WhereClause as a bandwidth optimisation when a condition-type filter is present. +/// The client-side gate in DataConnectionActor remains authoritative; these tests +/// only pin the filter-shaping. No live server required — pure SDK object building. +/// +public class RealOpcUaClientAlarmFilterTests +{ + [Fact] + public void BuildAlarmEventFilter_NoFilter_HasNoWhereClause() + { + var filter = RealOpcUaClient.BuildAlarmEventFilter(AlarmConditionFilter.AllowAll); + Assert.NotEmpty(filter.SelectClauses); + Assert.Empty(filter.WhereClause.Elements); + } + + [Fact] + public void BuildAlarmEventFilter_WithKnownTypes_BuildsNonEmptyWhereClause() + { + var parsed = AlarmConditionFilter.Parse("LimitAlarmType,DiscreteAlarmType"); + var filter = RealOpcUaClient.BuildAlarmEventFilter(parsed); + + Assert.NotEmpty(filter.WhereClause.Elements); + // Two known types → two OfType operands (OR'd when more than one). + var ofTypeCount = filter.WhereClause.Elements.Count(e => e.FilterOperator == FilterOperator.OfType); + Assert.Equal(2, ofTypeCount); + Assert.Contains(filter.WhereClause.Elements, e => e.FilterOperator == FilterOperator.Or); + } + + [Fact] + public void BuildAlarmEventFilter_SingleKnownType_BuildsSingleOfType_NoOr() + { + var parsed = AlarmConditionFilter.Parse("AlarmConditionType"); + var filter = RealOpcUaClient.BuildAlarmEventFilter(parsed); + + Assert.Single(filter.WhereClause.Elements); + Assert.Equal(FilterOperator.OfType, filter.WhereClause.Elements[0].FilterOperator); + } + + [Fact] + public void BuildAlarmEventFilter_TypeMatchingIsCaseInsensitive() + { + var parsed = AlarmConditionFilter.Parse("limitalarmtype"); + var filter = RealOpcUaClient.BuildAlarmEventFilter(parsed); + Assert.Single(filter.WhereClause.Elements, e => e.FilterOperator == FilterOperator.OfType); + } + + [Fact] + public void BuildAlarmEventFilter_AllUnknownTypes_OmitsWhereClause() + { + // Custom/vendor type names we cannot map to standard NodeIds are skipped + // server-side; the client-side gate still enforces them. Omitting the + // WhereClause is the safe choice — a partial WhereClause would drop the + // unmapped types at the server and break correctness. + var parsed = AlarmConditionFilter.Parse("MyVendorCustomAlarm,AnotherCustomThing"); + var filter = RealOpcUaClient.BuildAlarmEventFilter(parsed); + Assert.Empty(filter.WhereClause.Elements); + } + + [Fact] + public void BuildAlarmEventFilter_MixedKnownAndUnknown_OmitsWhereClause() + { + // If ANY requested type can't be mapped, a server-side WhereClause would + // silently drop that type's events — so we omit the optimisation entirely + // and let the (authoritative) client gate do the filtering. + var parsed = AlarmConditionFilter.Parse("LimitAlarmType,MyVendorCustomAlarm"); + var filter = RealOpcUaClient.BuildAlarmEventFilter(parsed); + Assert.Empty(filter.WhereClause.Elements); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/AlarmConditionFilterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/AlarmConditionFilterTests.cs new file mode 100644 index 00000000..b7589e35 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/AlarmConditionFilterTests.cs @@ -0,0 +1,99 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.DataConnectionLayer; + +namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests; + +/// +/// M2.4 (#8): the alarm conditionFilter is a comma-separated, case-insensitive +/// list of condition type names. Blank = allow all. These tests pin the +/// parse-once / IsAllowed predicate that the DataConnectionActor uses as the +/// authoritative client-side gate. +/// +public class AlarmConditionFilterTests +{ + private static NativeAlarmTransition Tx(string typeName, + AlarmTransitionKind kind = AlarmTransitionKind.Raise) => + new("ref", "obj", typeName, kind, + new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 500), + "cat", "desc", "msg", "", "", null, DateTimeOffset.UtcNow, "1", "0"); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData(",")] + [InlineData(" , , ")] + public void NullOrBlankFilter_IsEmpty_AllowsEverything(string? filter) + { + var f = AlarmConditionFilter.Parse(filter); + Assert.True(f.IsEmpty); + Assert.True(f.IsAllowed(Tx("AnalogLimit.Hi"))); + Assert.True(f.IsAllowed(Tx("anything-at-all"))); + } + + [Fact] + public void Parse_SplitsCommaSeparatedList() + { + var f = AlarmConditionFilter.Parse("AnalogLimit.Hi,DiscreteAlarm,AnalogLimit.Lo"); + Assert.False(f.IsEmpty); + Assert.True(f.IsAllowed(Tx("AnalogLimit.Hi"))); + Assert.True(f.IsAllowed(Tx("DiscreteAlarm"))); + Assert.True(f.IsAllowed(Tx("AnalogLimit.Lo"))); + Assert.False(f.IsAllowed(Tx("AnalogLimit.HiHi"))); + } + + [Fact] + public void IsAllowed_IsCaseInsensitive() + { + var f = AlarmConditionFilter.Parse("AnalogLimit.Hi"); + Assert.True(f.IsAllowed(Tx("analoglimit.hi"))); + Assert.True(f.IsAllowed(Tx("ANALOGLIMIT.HI"))); + Assert.False(f.IsAllowed(Tx("DiscreteAlarm"))); + } + + [Fact] + public void Parse_TrimsWhitespaceAroundEachName() + { + var f = AlarmConditionFilter.Parse(" AnalogLimit.Hi ,\tDiscreteAlarm "); + Assert.True(f.IsAllowed(Tx("AnalogLimit.Hi"))); + Assert.True(f.IsAllowed(Tx("DiscreteAlarm"))); + } + + [Fact] + public void Parse_DropsEmptyEntries_KeepsNonEmpty() + { + var f = AlarmConditionFilter.Parse("AnalogLimit.Hi,, ,DiscreteAlarm"); + Assert.False(f.IsEmpty); + Assert.True(f.IsAllowed(Tx("AnalogLimit.Hi"))); + Assert.True(f.IsAllowed(Tx("DiscreteAlarm"))); + Assert.False(f.IsAllowed(Tx(""))); + } + + [Fact] + public void IsAllowed_NeverDropsSnapshotCompleteFramingSentinel() + { + // SnapshotComplete is a pure framing sentinel (empty AlarmTypeName) that + // drives the NativeAlarmActor's atomic snapshot swap. A type filter must + // never swallow it or the snapshot replay never completes. + var f = AlarmConditionFilter.Parse("AnalogLimit.Hi"); + Assert.True(f.IsAllowed(Tx("", AlarmTransitionKind.SnapshotComplete))); + } + + [Fact] + public void IsAllowed_FiltersReplayedSnapshotConditionsByType() + { + // Snapshot-kind transitions carry real conditions and ARE filtered. + var f = AlarmConditionFilter.Parse("AnalogLimit.Hi"); + Assert.True(f.IsAllowed(Tx("AnalogLimit.Hi", AlarmTransitionKind.Snapshot))); + Assert.False(f.IsAllowed(Tx("DiscreteAlarm", AlarmTransitionKind.Snapshot))); + } + + [Fact] + public void Names_ExposesNormalizedSet_ForServerSideOptimization() + { + var f = AlarmConditionFilter.Parse(" AnalogLimit.Hi , DiscreteAlarm "); + Assert.Equal(new[] { "AnalogLimit.Hi", "DiscreteAlarm" }, f.Names.OrderBy(n => n).ToArray()); + Assert.Empty(AlarmConditionFilter.Parse(null).Names); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionActorAlarmTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionActorAlarmTests.cs index bc321570..cd685897 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionActorAlarmTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionActorAlarmTests.cs @@ -23,10 +23,27 @@ public class DataConnectionActorAlarmTests : TestKit }; private static NativeAlarmTransition Raise(string sourceRef, string sourceObj) => - new(sourceRef, sourceObj, "AnalogLimit.Hi", AlarmTransitionKind.Raise, + Raise(sourceRef, sourceObj, "AnalogLimit.Hi"); + + private static NativeAlarmTransition Raise(string sourceRef, string sourceObj, string typeName, + AlarmTransitionKind kind = AlarmTransitionKind.Raise) => + new(sourceRef, sourceObj, typeName, kind, new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 500), "Process", "hi", "hi", "", "", null, DateTimeOffset.UtcNow, "92", "90"); + private static (IDataConnection Adapter, Func Cb) BuildAlarmAdapter() + { + AlarmTransitionCallback? cb = null; + var adapter = Substitute.For(); + adapter.ConnectAsync(Arg.Any>(), Arg.Any()) + .Returns(Task.CompletedTask); + ((IAlarmSubscribableConnection)adapter) + .SubscribeAlarmsAsync(Arg.Any(), Arg.Any(), + Arg.Do(c => cb = c), Arg.Any()) + .Returns(Task.FromResult("alarm-sub-1")); + return (adapter, () => cb); + } + [Fact] public void SubscribeAlarms_RoutesTransitionToInstanceSubscriber() { @@ -63,4 +80,119 @@ public class DataConnectionActorAlarmTests : TestKit actor.Tell(new SubscribeAlarmsRequest("c", "inst", "conn", "Tank01", null, DateTimeOffset.UtcNow)); ExpectMsg(m => !m.Success && m.ErrorMessage != null); } + + // ── M2.4 (#8): conditionFilter is now applied client-side in the actor ── + + [Fact] + public void SubscribeAlarms_WithTypeFilter_DeliversOnlyMatchingTypes() + { + var (adapter, getCb) = BuildAlarmAdapter(); + var actor = Sys.ActorOf(Props.Create(() => new DataConnectionActor( + "conn", adapter, _options, _health, _factory, "OpcUa"))); + + actor.Tell(new SubscribeAlarmsRequest("c", "inst", "conn", "Tank01", + "AnalogLimit.Hi,AnalogLimit.Lo", DateTimeOffset.UtcNow)); + ExpectMsg(m => m.Success); + var cb = getCb(); + Assert.NotNull(cb); + + // Non-matching type is dropped (no message delivered). + cb!(Raise("Tank01.HiHi", "Tank01", "AnalogLimit.HiHi")); + ExpectNoMsg(TimeSpan.FromMilliseconds(250)); + + // Matching type is delivered. + cb!(Raise("Tank01.Hi", "Tank01", "AnalogLimit.Hi")); + ExpectMsg(u => u.Transition.AlarmTypeName == "AnalogLimit.Hi"); + } + + [Fact] + public void SubscribeAlarms_WithNullFilter_DeliversAllTypes() + { + var (adapter, getCb) = BuildAlarmAdapter(); + var actor = Sys.ActorOf(Props.Create(() => new DataConnectionActor( + "conn", adapter, _options, _health, _factory, "OpcUa"))); + + actor.Tell(new SubscribeAlarmsRequest("c", "inst", "conn", "Tank01", null, DateTimeOffset.UtcNow)); + ExpectMsg(m => m.Success); + var cb = getCb(); + Assert.NotNull(cb); + + cb!(Raise("Tank01.HiHi", "Tank01", "AnalogLimit.HiHi")); + ExpectMsg(u => u.Transition.AlarmTypeName == "AnalogLimit.HiHi"); + cb!(Raise("Tank01.Lo", "Tank01", "DiscreteAlarm")); + ExpectMsg(u => u.Transition.AlarmTypeName == "DiscreteAlarm"); + } + + [Fact] + public void SubscribeAlarms_FilterMatch_IgnoresCaseAndWhitespace() + { + var (adapter, getCb) = BuildAlarmAdapter(); + var actor = Sys.ActorOf(Props.Create(() => new DataConnectionActor( + "conn", adapter, _options, _health, _factory, "OpcUa"))); + + actor.Tell(new SubscribeAlarmsRequest("c", "inst", "conn", "Tank01", + " analoglimit.hi ,\tDISCRETEALARM ", DateTimeOffset.UtcNow)); + ExpectMsg(m => m.Success); + var cb = getCb(); + Assert.NotNull(cb); + + cb!(Raise("Tank01.Hi", "Tank01", "AnalogLimit.Hi")); // case differs from filter + ExpectMsg(u => u.Transition.AlarmTypeName == "AnalogLimit.Hi"); + cb!(Raise("Tank01.Disc", "Tank01", "DiscreteAlarm")); + ExpectMsg(u => u.Transition.AlarmTypeName == "DiscreteAlarm"); + cb!(Raise("Tank01.HiHi", "Tank01", "AnalogLimit.HiHi")); // not listed + ExpectNoMsg(TimeSpan.FromMilliseconds(250)); + } + + [Fact] + public void SubscribeAlarms_GatewayWideFeed_IsFilteredClientSide() + { + // MxGateway has no server-side filter: its adapter opens ONE gateway-wide + // feed and the actor is the authoritative gate. A filtered source must + // only see its own matching types even though the feed carries everything. + var (adapter, getCb) = BuildAlarmAdapter(); + var actor = Sys.ActorOf(Props.Create(() => new DataConnectionActor( + "conn", adapter, _options, _health, _factory, "MxGateway"))); + + actor.Tell(new SubscribeAlarmsRequest("c", "inst", "conn", "Reactor", + "HighTemp", DateTimeOffset.UtcNow)); + ExpectMsg(m => m.Success); + var cb = getCb(); + Assert.NotNull(cb); + + // Gateway-wide feed delivers a transition for a different source object — + // dropped by source routing. + cb!(Raise("Pump.Fault", "Pump", "HighTemp")); + ExpectNoMsg(TimeSpan.FromMilliseconds(200)); + // Right source, wrong type — dropped by the client-side type gate. + cb!(Raise("Reactor.LowTemp", "Reactor", "LowTemp")); + ExpectNoMsg(TimeSpan.FromMilliseconds(200)); + // Right source, right type — delivered. + cb!(Raise("Reactor.HighTemp", "Reactor", "HighTemp")); + ExpectMsg(u => + u.Transition.SourceObjectReference == "Reactor" && u.Transition.AlarmTypeName == "HighTemp"); + } + + [Fact] + public void SubscribeAlarms_WithFilter_StillForwardsSnapshotCompleteSentinel() + { + // The SnapshotComplete framing sentinel (empty AlarmTypeName) must survive + // the type gate so the NativeAlarmActor's snapshot swap can complete. + var (adapter, getCb) = BuildAlarmAdapter(); + var actor = Sys.ActorOf(Props.Create(() => new DataConnectionActor( + "conn", adapter, _options, _health, _factory, "OpcUa"))); + + actor.Tell(new SubscribeAlarmsRequest("c", "inst", "conn", "Tank01", + "AnalogLimit.Hi", DateTimeOffset.UtcNow)); + ExpectMsg(m => m.Success); + var cb = getCb(); + Assert.NotNull(cb); + + // Snapshot-complete sentinel: empty source refs (the framing marker) but + // routed because every subscriber receives it; never type-filtered. + cb!(new NativeAlarmTransition("Tank01", "Tank01", "", AlarmTransitionKind.SnapshotComplete, + new AlarmConditionState(false, true, null, AlarmShelveState.Unshelved, false, 0), + "", "", "", "", "", null, DateTimeOffset.UtcNow, "", "")); + ExpectMsg(u => u.Transition.Kind == AlarmTransitionKind.SnapshotComplete); + } }