From 3ccf0b5f9edc6ec3967d4d097e457052c78a7120 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 13 Jun 2026 10:12:58 -0400 Subject: [PATCH] server(alarms): honor ExcludeAttributes GR-only contract; warn on empty config-only watch-list --- .../Alarms/AlarmWatchListResolver.cs | 26 ++++--- .../Alarms/AlarmWatchListResolverTests.cs | 70 +++++++++++++++++++ 2 files changed, 88 insertions(+), 8 deletions(-) diff --git a/src/ZB.MOM.WW.MxGateway.Server/Alarms/AlarmWatchListResolver.cs b/src/ZB.MOM.WW.MxGateway.Server/Alarms/AlarmWatchListResolver.cs index fd89a26..8b5d581 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Alarms/AlarmWatchListResolver.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Alarms/AlarmWatchListResolver.cs @@ -87,12 +87,19 @@ public sealed class AlarmWatchListResolver : IAlarmWatchListResolver ordered.Add((include, DeriveSourceObject(include))); } - // Remove excluded references (case-insensitive). Excludes are rare, so a - // per-entry lookup against a small set is fine. - HashSet excluded = new(discovery.ExcludeAttributes, StringComparer.OrdinalIgnoreCase); - if (excluded.Count > 0) + // Remove excluded references (case-insensitive), but only when GR discovery + // is active. ExcludeAttributes is documented as "Ignored when + // UseGalaxyRepository is false" (AlarmDiscoveryOptions.ExcludeAttributes). + // Whitespace-only entries are skipped, consistent with the include guard above. + if (discovery.UseGalaxyRepository) { - ordered.RemoveAll(e => excluded.Contains(e.Reference)); + HashSet excluded = new( + discovery.ExcludeAttributes.Where(e => !string.IsNullOrWhiteSpace(e)), + StringComparer.OrdinalIgnoreCase); + if (excluded.Count > 0) + { + ordered.RemoveAll(e => excluded.Contains(e.Reference)); + } } // 2. Resolve subtag names with safe fallbacks. @@ -123,13 +130,16 @@ public sealed class AlarmWatchListResolver : IAlarmWatchListResolver }); } - // 5. Report the resolved count; warn if subtag mode would cover nothing. - if (targets.Count == 0 && discovery.UseGalaxyRepository) + // 5. Report the resolved count; warn when subtag mode was expected to cover + // something (GR enabled, or explicit includes were configured) but resolved + // to nothing. Only emit the Debug line when there is at least one target, + // to avoid a confusing "0 target(s)" noise line. + if (targets.Count == 0 && (discovery.UseGalaxyRepository || discovery.IncludeAttributes.Length > 0)) { _logger.LogWarning( "Alarm subtag watch-list resolved to zero targets; subtag-polling fallback will cover no alarms."); } - else + else if (targets.Count > 0) { _logger.LogDebug("Resolved alarm subtag watch-list with {TargetCount} target(s).", targets.Count); } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmWatchListResolverTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmWatchListResolverTests.cs index c6b5262..d855ab4 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmWatchListResolverTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmWatchListResolverTests.cs @@ -208,6 +208,76 @@ public sealed class AlarmWatchListResolverTests Assert.Equal("StandaloneTag", result[1].SourceObjectReference); } + /// + /// Fix 1: ExcludeAttributes must be ignored when UseGalaxyRepository is false. + /// A config-only include must survive even when the same path appears in ExcludeAttributes. + /// + [Fact] + public async Task ResolveAsync_ExcludeIgnored_WhenGalaxyRepositoryDisabled() + { + // Repo is never consulted; only IncludeAttributes matters. + StubGalaxyRepository repo = new([]); + + AlarmWatchListResolver resolver = CreateResolver(repo); + + IReadOnlyList result = await resolver.ResolveAsync(Options( + useGalaxyRepository: false, + include: ["Tank01.Level.HiHi"], + exclude: ["Tank01.Level.HiHi"])); + + // ExcludeAttributes is ignored when GR is off — the include must be present. + AlarmSubtagTarget target = Assert.Single(result); + Assert.Equal("Galaxy!Tank01.Level.HiHi", target.AlarmFullReference); + } + + /// + /// Fix 1 (GR-on path): ExcludeAttributes still prunes GR rows when + /// UseGalaxyRepository is true. + /// + [Fact] + public async Task ResolveAsync_ExcludeApplied_WhenGalaxyRepositoryEnabled() + { + StubGalaxyRepository repo = new( + [ + new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01" }, + new GalaxyAlarmAttributeRow { FullTagReference = "Tank02.Level.HiHi", SourceObjectReference = "Tank02" }, + ]); + + AlarmWatchListResolver resolver = CreateResolver(repo); + + IReadOnlyList result = await resolver.ResolveAsync(Options( + useGalaxyRepository: true, + exclude: ["Tank02.Level.HiHi"])); + + // Tank02 was excluded; only Tank01 remains. + AlarmSubtagTarget target = Assert.Single(result); + Assert.Contains("Tank01", target.ActiveSubtag, StringComparison.Ordinal); + } + + /// + /// Fix 1: A whitespace-only ExcludeAttributes entry must be skipped and must + /// not accidentally exclude any real reference. + /// + [Fact] + public async Task ResolveAsync_WhitespaceOnlyExcludeEntry_IsSkipped() + { + StubGalaxyRepository repo = new( + [ + new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01" }, + ]); + + AlarmWatchListResolver resolver = CreateResolver(repo); + + // The exclude array contains a whitespace-only string — should be a no-op. + IReadOnlyList result = await resolver.ResolveAsync(Options( + useGalaxyRepository: true, + exclude: [" "])); + + // Tank01 must not have been wrongly excluded. + AlarmSubtagTarget target = Assert.Single(result); + Assert.Contains("Tank01", target.ActiveSubtag, StringComparison.Ordinal); + } + /// In-memory returning a fixed alarm rowset. private sealed class StubGalaxyRepository(List rows) : IGalaxyRepository {