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());
}
}
}