using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Server.Alarms; using ZB.MOM.WW.MxGateway.Server.Configuration; using ZB.MOM.WW.MxGateway.Server.Galaxy; 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); 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(), }, }; [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 resolver.ResolveAsync(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); } [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 resolver.ResolveAsync(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); } [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 resolver.ResolveAsync(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); } [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 resolver.ResolveAsync( Options(area: "DEV", defaultArea: "DEV")); AlarmSubtagTarget target = Assert.Single(result); Assert.Equal("Galaxy!TestArea.TestMachine_001.TestAlarm001", target.AlarmFullReference); } [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 resolver.ResolveAsync(Options()); AlarmSubtagTarget target = Assert.Single(result); Assert.Equal("Galaxy!Tank01.Level.HiHi", target.AlarmFullReference); } [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 resolver.ResolveAsync(Options( area: "Site_A", include: ["Tank01.Level.HiHi"])); AlarmSubtagTarget target = Assert.Single(result); Assert.Equal("Galaxy!Site_A.Tank01.Level.HiHi", target.AlarmFullReference); } [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 resolver.ResolveAsync(Options( area: "", defaultArea: "Plant", include: ["Tank01.Level.HiHi"])); AlarmSubtagTarget target = Assert.Single(result); Assert.Equal("Galaxy!Plant.Tank01.Level.HiHi", target.AlarmFullReference); } [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 resolver.ResolveAsync(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); } [Fact] public async Task ResolveAsync_RepositoryThrows_LogsAndReturnsConfigOnlySet() { ThrowingGalaxyRepository repo = new(new InvalidOperationException("SQL unavailable")); AlarmWatchListResolver resolver = CreateResolver(repo); IReadOnlyList result = await resolver.ResolveAsync(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); } [Fact] public async Task ResolveAsync_DerivesSourceObjectForConfigEntry() { StubGalaxyRepository repo = new([]); AlarmWatchListResolver resolver = CreateResolver(repo); IReadOnlyList result = await resolver.ResolveAsync(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. /// [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", Area = "TestArea" }, new GalaxyAlarmAttributeRow { FullTagReference = "Tank02.Level.HiHi", SourceObjectReference = "Tank02", Area = "TestArea" }, ]); 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", Area = "TestArea" }, ]); 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 { /// Gets the number of times was called. public int GetAlarmAttributesCount { get; private set; } /// public Task TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(true); /// public Task GetLastDeployTimeAsync(CancellationToken ct = default) => Task.FromResult(null); /// public Task> GetHierarchyAsync(CancellationToken ct = default) => Task.FromResult(new List()); /// public Task> GetAttributesAsync(CancellationToken ct = default) => Task.FromResult(new List()); /// public Task> GetAlarmAttributesAsync(CancellationToken ct = default) { GetAlarmAttributesCount++; return Task.FromResult(rows); } } /// whose alarm-attribute query throws. private sealed class ThrowingGalaxyRepository(Exception toThrow) : IGalaxyRepository { /// public Task TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(true); /// public Task GetLastDeployTimeAsync(CancellationToken ct = default) => Task.FromResult(null); /// public Task> GetHierarchyAsync(CancellationToken ct = default) => Task.FromResult(new List()); /// public Task> GetAttributesAsync(CancellationToken ct = default) => Task.FromResult(new List()); /// public Task> GetAlarmAttributesAsync(CancellationToken ct = default) => Task.FromException>(toThrow); } }