using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.GalaxyRepository; using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Server.Alarms; using ZB.MOM.WW.MxGateway.Server.Configuration; namespace ZB.MOM.WW.MxGateway.Tests.Alarms; /// /// Unit tests for : discovery/config merge, /// subtag-address composition, canonical reference shaping, and the /// unavailable-discovery code path. /// public sealed class AlarmWatchListResolverTests { private static AlarmWatchListResolver CreateResolver(IGalaxyRepository repository) => new(repository, NullLogger.Instance); /// /// Resolves and projects to the target list. These cases assert composition rules, which /// live in ; the discovery-provenance fields /// the resolution also carries are asserted by their own cases below. /// /// The resolver under test. /// Alarm options to resolve against. /// Token to cancel the resolution. /// The resolved targets. private static async Task> ResolveTargetsAsync( AlarmWatchListResolver resolver, AlarmsOptions options, CancellationToken cancellationToken = default) => (await resolver.ResolveAsync(options, cancellationToken)).Targets; private static AlarmsOptions Options( bool useGalaxyRepository = true, string area = "", string defaultArea = "", string[]? include = null, string[]? exclude = null, AlarmSubtagNameOptions? subtags = null) => new() { DefaultArea = defaultArea, Fallback = new AlarmFallbackOptions { Discovery = new AlarmDiscoveryOptions { UseGalaxyRepository = useGalaxyRepository, Area = area, IncludeAttributes = include ?? [], ExcludeAttributes = exclude ?? [], }, Subtags = subtags ?? new AlarmSubtagNameOptions(), }, }; /// /// Verifies Galaxy Repository rows and config includes are unioned, configured /// excludes are removed, and duplicate references (case-insensitive) collapse /// to a single entry. /// /// A task that represents the asynchronous operation. [Fact] public async Task ResolveAsync_UnionsGalaxyRowsAndIncludes_RemovesExcludes_AndDeduplicates() { StubGalaxyRepository repo = new( [ new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01", Area = "TestArea" }, new GalaxyAlarmAttributeRow { FullTagReference = "Tank02.Level.HiHi", SourceObjectReference = "Tank02", Area = "TestArea" }, // Duplicate of an include below (case-insensitive) — should appear once. new GalaxyAlarmAttributeRow { FullTagReference = "Pump01.Fault", SourceObjectReference = "Pump01", Area = "TestArea" }, ]); AlarmWatchListResolver resolver = CreateResolver(repo); IReadOnlyList result = await ResolveTargetsAsync(resolver, Options( include: ["pump01.fault", "Valve03.Position.Lo"], exclude: ["Tank02.Level.HiHi"])); Assert.Equal( new[] { "Tank01.Level.HiHi", "Pump01.Fault", "Valve03.Position.Lo" }, result.Select(t => t.ActiveSubtag.Replace(".InAlarm", string.Empty, StringComparison.Ordinal))); // De-dup preserved first (GR) occurrence; exclude removed Tank02. Assert.Equal(3, result.Count); } /// Verifies subtag item addresses are composed using the configured subtag names. /// A task that represents the asynchronous operation. [Fact] public async Task ResolveAsync_ComposesSubtagAddressesFromConfigNames() { StubGalaxyRepository repo = new( [ new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01", Area = "TestArea" }, ]); AlarmWatchListResolver resolver = CreateResolver(repo); IReadOnlyList result = await ResolveTargetsAsync(resolver, Options( subtags: new AlarmSubtagNameOptions { Active = "InAlarm", Acked = "Ack", Priority = "Pri", AckComment = "AckCmt", })); AlarmSubtagTarget target = Assert.Single(result); Assert.Equal("Tank01.Level.HiHi.InAlarm", target.ActiveSubtag); Assert.Equal("Tank01.Level.HiHi.Ack", target.AckedSubtag); Assert.Equal("Tank01.Level.HiHi.Pri", target.PrioritySubtag); Assert.Equal("Tank01.Level.HiHi.AckCmt", target.AckCommentSubtag); } /// Verifies that configuring empty Priority and AckComment subtag names leaves those target fields empty. /// A task that represents the asynchronous operation. [Fact] public async Task ResolveAsync_EmptyPriorityAndAckComment_LeaveThoseFieldsEmpty() { StubGalaxyRepository repo = new( [ new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01", Area = "TestArea" }, ]); AlarmWatchListResolver resolver = CreateResolver(repo); // Default Priority is "Priority"; force it empty alongside empty AckComment. IReadOnlyList result = await ResolveTargetsAsync(resolver, Options( subtags: new AlarmSubtagNameOptions { Priority = string.Empty, AckComment = string.Empty })); AlarmSubtagTarget target = Assert.Single(result); Assert.Equal("Tank01.Level.HiHi.InAlarm", target.ActiveSubtag); Assert.Equal("Tank01.Level.HiHi.Acked", target.AckedSubtag); Assert.Equal(string.Empty, target.PrioritySubtag); Assert.Equal(string.Empty, target.AckCommentSubtag); } /// Verifies the canonical full reference uses the row's discovered Galaxy area, not the configured Discovery.Area. /// A task that represents the asynchronous operation. [Fact] public async Task ResolveAsync_ComposesCanonicalFullReference_FromRealGalaxyArea_NotConfigArea() { // The GR row carries the object's real Galaxy area (the alarmmgr group). The // composed reference must use that area, NOT the configured Discovery.Area. StubGalaxyRepository repo = new( [ new GalaxyAlarmAttributeRow { FullTagReference = "TestMachine_001.TestAlarm001", SourceObjectReference = "TestMachine_001", Area = "TestArea", }, ]); AlarmWatchListResolver resolver = CreateResolver(repo); // Config area "DEV" must be ignored for a GR row that has a discovered area. IReadOnlyList result = await ResolveTargetsAsync(resolver, Options(area: "DEV", defaultArea: "DEV")); AlarmSubtagTarget target = Assert.Single(result); Assert.Equal("Galaxy!TestArea.TestMachine_001.TestAlarm001", target.AlarmFullReference); } /// Verifies the canonical full reference omits the area segment when neither a discovered nor a configured area is present. /// A task that represents the asynchronous operation. [Fact] public async Task ResolveAsync_ComposesCanonicalFullReference_WithoutArea() { // GR row with no discovered area and no config area -> bare Galaxy!{reference}. StubGalaxyRepository repo = new( [ new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01", Area = "" }, ]); AlarmWatchListResolver resolver = CreateResolver(repo); // No discovery area and no default area. IReadOnlyList result = await ResolveTargetsAsync(resolver, Options()); AlarmSubtagTarget target = Assert.Single(result); Assert.Equal("Galaxy!Tank01.Level.HiHi", target.AlarmFullReference); } /// Verifies a config include entry with no discovered area falls back to the configured Discovery.Area. /// A task that represents the asynchronous operation. [Fact] public async Task ResolveAsync_ConfigInclude_UsesDiscoveryAreaFallback() { // A config IncludeAttributes entry has no discovered area, so it uses the // config fallback: Discovery.Area when set. StubGalaxyRepository repo = new([]); AlarmWatchListResolver resolver = CreateResolver(repo); IReadOnlyList result = await ResolveTargetsAsync(resolver, Options( area: "Site_A", include: ["Tank01.Level.HiHi"])); AlarmSubtagTarget target = Assert.Single(result); Assert.Equal("Galaxy!Site_A.Tank01.Level.HiHi", target.AlarmFullReference); } /// Verifies a config include entry falls back to DefaultArea when Discovery.Area is empty. /// A task that represents the asynchronous operation. [Fact] public async Task ResolveAsync_ConfigInclude_FallsBackToDefaultArea_WhenDiscoveryAreaEmpty() { // A config IncludeAttributes entry with no Discovery.Area uses DefaultArea. StubGalaxyRepository repo = new([]); AlarmWatchListResolver resolver = CreateResolver(repo); IReadOnlyList result = await ResolveTargetsAsync(resolver, Options( area: "", defaultArea: "Plant", include: ["Tank01.Level.HiHi"])); AlarmSubtagTarget target = Assert.Single(result); Assert.Equal("Galaxy!Plant.Tank01.Level.HiHi", target.AlarmFullReference); } /// Verifies that disabling Galaxy Repository discovery skips the repository call and resolves the watch list from configured includes only. /// A task that represents the asynchronous operation. [Fact] public async Task ResolveAsync_UseGalaxyRepositoryFalse_DoesNotCallRepository_UsesIncludesOnly() { StubGalaxyRepository repo = new( [ new GalaxyAlarmAttributeRow { FullTagReference = "ShouldNotAppear.X", SourceObjectReference = "ShouldNotAppear" }, ]); AlarmWatchListResolver resolver = CreateResolver(repo); IReadOnlyList result = await ResolveTargetsAsync(resolver, Options( useGalaxyRepository: false, include: ["Tank01.Level.HiHi"])); Assert.Equal(0, repo.GetAlarmAttributesCount); AlarmSubtagTarget target = Assert.Single(result); Assert.Equal("Galaxy!Tank01.Level.HiHi", target.AlarmFullReference); } /// Verifies that a repository exception during discovery is logged and swallowed, returning the config-only watch list instead of throwing. /// A task that represents the asynchronous operation. [Fact] public async Task ResolveAsync_RepositoryThrows_LogsAndReturnsConfigOnlySet() { ThrowingGalaxyRepository repo = new(new InvalidOperationException("SQL unavailable")); AlarmWatchListResolver resolver = CreateResolver(repo); IReadOnlyList result = await ResolveTargetsAsync(resolver, Options( include: ["Tank01.Level.HiHi"])); // Did not throw; discovery set empty, include retained. AlarmSubtagTarget target = Assert.Single(result); Assert.Equal("Galaxy!Tank01.Level.HiHi", target.AlarmFullReference); } /// /// Swallowing the discovery failure is right for the resolver but leaves the /// caller unable to tell "discovery produced nothing" from "nothing was configured", which /// is why the empty watch-list could sit unnoticed for a monitor's whole life. The /// resolution therefore reports whether discovery was attempted, whether it failed, and /// the failure message the provider status quotes. /// /// A task that represents the asynchronous operation. [Fact] public async Task ResolveAsync_RepositoryThrows_ReportsDiscoveryFailureOnTheResolution() { ThrowingGalaxyRepository repo = new(new InvalidOperationException("SQL unavailable")); AlarmWatchListResolver resolver = CreateResolver(repo); AlarmWatchListResolution resolution = await resolver.ResolveAsync(Options()); Assert.Empty(resolution.Targets); Assert.True(resolution.DiscoveryAttempted); Assert.True(resolution.DiscoveryFailed); Assert.Equal("SQL unavailable", resolution.DiscoveryFailureMessage); } /// /// The complement of the case above: discovery disabled is not discovery failed. An empty /// watch-list here means the deployment did not ask for subtag coverage, which the monitor /// must not report as a degraded standby. /// /// A task that represents the asynchronous operation. [Fact] public async Task ResolveAsync_RepositoryDisabled_ReportsDiscoveryNotAttempted() { StubGalaxyRepository repo = new([]); AlarmWatchListResolver resolver = CreateResolver(repo); AlarmWatchListResolution resolution = await resolver.ResolveAsync( Options(useGalaxyRepository: false)); Assert.Empty(resolution.Targets); Assert.False(resolution.DiscoveryAttempted); Assert.False(resolution.DiscoveryFailed); Assert.Null(resolution.DiscoveryFailureMessage); } /// Verifies the source object reference is derived from a config include entry, using the portion before the first dot when present. /// A task that represents the asynchronous operation. [Fact] public async Task ResolveAsync_DerivesSourceObjectForConfigEntry() { StubGalaxyRepository repo = new([]); AlarmWatchListResolver resolver = CreateResolver(repo); IReadOnlyList result = await ResolveTargetsAsync(resolver, Options( include: ["Tank01.Level.HiHi", "StandaloneTag"])); Assert.Equal("Tank01", result[0].SourceObjectReference); // No dot — whole string is the source object. 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. /// /// A task that represents the asynchronous operation. [Fact] public async Task ResolveAsync_ExcludeIgnored_WhenGalaxyRepositoryDisabled() { // Repo is never consulted; only IncludeAttributes matters. StubGalaxyRepository repo = new([]); AlarmWatchListResolver resolver = CreateResolver(repo); IReadOnlyList result = await ResolveTargetsAsync(resolver, 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. /// /// A task that represents the asynchronous operation. [Fact] public async Task ResolveAsync_ExcludeApplied_WhenGalaxyRepositoryEnabled() { StubGalaxyRepository repo = new( [ new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01", Area = "TestArea" }, new GalaxyAlarmAttributeRow { FullTagReference = "Tank02.Level.HiHi", SourceObjectReference = "Tank02", Area = "TestArea" }, ]); AlarmWatchListResolver resolver = CreateResolver(repo); IReadOnlyList result = await ResolveTargetsAsync(resolver, 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. /// /// A task that represents the asynchronous operation. [Fact] public async Task ResolveAsync_WhitespaceOnlyExcludeEntry_IsSkipped() { StubGalaxyRepository repo = new( [ new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01", Area = "TestArea" }, ]); AlarmWatchListResolver resolver = CreateResolver(repo); // The exclude array contains a whitespace-only string — should be a no-op. IReadOnlyList result = await ResolveTargetsAsync(resolver, Options( useGalaxyRepository: true, exclude: [" "])); // Tank01 must not have been wrongly excluded. AlarmSubtagTarget target = Assert.Single(result); Assert.Contains("Tank01", target.ActiveSubtag, StringComparison.Ordinal); } /// /// A cancellation triggered while Galaxy Repository discovery is /// awaiting must propagate as , not be /// swallowed into a config-only watch-list, per the /// contract. /// /// A task that represents the asynchronous operation. [Fact] public async Task ResolveAsync_RepositoryCancelled_PropagatesOperationCanceled() { using CancellationTokenSource cts = new(); // Repository observes the token, cancels the source, then throws the matching // OperationCanceledException — exactly what the live SQL path does on shutdown. CancellingGalaxyRepository repo = new(cts); AlarmWatchListResolver resolver = CreateResolver(repo); await Assert.ThrowsAnyAsync(() => ResolveTargetsAsync(resolver, Options(include: ["Tank01.Level.HiHi"]), cts.Token)); } /// /// An entry that appears in both /// IncludeAttributes and ExcludeAttributes is removed — excludes /// win over explicit includes (the documented "excludes also suppress matching /// explicit includes" behaviour). /// /// A task that represents the asynchronous operation. [Fact] public async Task ResolveAsync_ExcludeAlsoSuppressesMatchingExplicitInclude() { StubGalaxyRepository repo = new( [ new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01", Area = "TestArea" }, ]); AlarmWatchListResolver resolver = CreateResolver(repo); // Pump01.Fault is an explicit include AND an exclude (case-insensitive). It must // be dropped; the GR row Tank01 survives. IReadOnlyList result = await ResolveTargetsAsync(resolver, Options( useGalaxyRepository: true, include: ["Pump01.Fault"], exclude: ["pump01.fault"])); AlarmSubtagTarget target = Assert.Single(result); Assert.Contains("Tank01", target.ActiveSubtag, StringComparison.Ordinal); Assert.DoesNotContain(result, t => t.ActiveSubtag.Contains("Pump01", StringComparison.OrdinalIgnoreCase)); } /// In-memory returning a fixed alarm rowset. private sealed class StubGalaxyRepository(List rows) : IGalaxyRepository { /// Gets the number of times was called. public int GetAlarmAttributesCount { get; private set; } /// Always reports a successful connection. /// Token to observe for cancellation. /// A completed task carrying . public Task TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(true); /// Reports no known deploy time. /// Token to observe for cancellation. /// A completed task carrying . public Task GetLastDeployTimeAsync(CancellationToken ct = default) => Task.FromResult(null); /// Not exercised by these tests; returns an empty hierarchy. /// Token to observe for cancellation. /// A completed task carrying an empty list. public Task> GetHierarchyAsync(CancellationToken ct = default) => Task.FromResult(new List()); /// Not exercised by these tests; returns an empty attribute set. /// Token to observe for cancellation. /// A completed task carrying an empty list. public Task> GetAttributesAsync(CancellationToken ct = default) => Task.FromResult(new List()); /// Returns the fixed alarm rowset supplied at construction, tracking how many times it was called. /// Token to observe for cancellation. /// A completed task carrying the configured alarm attribute rows. public Task> GetAlarmAttributesAsync(CancellationToken ct = default) { GetAlarmAttributesCount++; return Task.FromResult(rows); } } /// whose alarm-attribute query throws. private sealed class ThrowingGalaxyRepository(Exception toThrow) : IGalaxyRepository { /// Always reports a successful connection; only alarm-attribute discovery fails for this fake. /// Token to observe for cancellation. /// A completed task carrying . public Task TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(true); /// Reports no known deploy time. /// Token to observe for cancellation. /// A completed task carrying . public Task GetLastDeployTimeAsync(CancellationToken ct = default) => Task.FromResult(null); /// Not exercised by these tests; returns an empty hierarchy. /// Token to observe for cancellation. /// A completed task carrying an empty list. public Task> GetHierarchyAsync(CancellationToken ct = default) => Task.FromResult(new List()); /// Not exercised by these tests; returns an empty attribute set. /// Token to observe for cancellation. /// A completed task carrying an empty list. public Task> GetAttributesAsync(CancellationToken ct = default) => Task.FromResult(new List()); /// Simulates the live SQL path failing by faulting with the configured exception. /// Token to observe for cancellation. /// A faulted task carrying the configured exception. public Task> GetAlarmAttributesAsync(CancellationToken ct = default) => Task.FromException>(toThrow); } /// /// whose alarm-attribute query cancels the /// supplied source and throws a token-bound , /// mirroring the live SQL path being cancelled mid-await. /// private sealed class CancellingGalaxyRepository(CancellationTokenSource source) : IGalaxyRepository { /// Always reports a successful connection; only alarm-attribute discovery cancels for this fake. /// Token to observe for cancellation. /// A completed task carrying . public Task TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(true); /// Reports no known deploy time. /// Token to observe for cancellation. /// A completed task carrying . public Task GetLastDeployTimeAsync(CancellationToken ct = default) => Task.FromResult(null); /// Not exercised by these tests; returns an empty hierarchy. /// Token to observe for cancellation. /// A completed task carrying an empty list. public Task> GetHierarchyAsync(CancellationToken ct = default) => Task.FromResult(new List()); /// Not exercised by these tests; returns an empty attribute set. /// Token to observe for cancellation. /// A completed task carrying an empty list. public Task> GetAttributesAsync(CancellationToken ct = default) => Task.FromResult(new List()); /// Cancels the supplied source and throws, mirroring the live SQL path being cancelled mid-await. /// Token to observe for cancellation. /// Never returns normally; always throws . public Task> GetAlarmAttributesAsync(CancellationToken ct = default) { source.Cancel(); ct.ThrowIfCancellationRequested(); return Task.FromResult(new List()); } } }