361 lines
15 KiB
C#
361 lines
15 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Unit tests for <see cref="AlarmWatchListResolver"/>: discovery/config merge,
|
|
/// subtag-address composition, canonical reference shaping, and the
|
|
/// unavailable-discovery code path.
|
|
/// </summary>
|
|
public sealed class AlarmWatchListResolverTests
|
|
{
|
|
private static AlarmWatchListResolver CreateResolver(IGalaxyRepository repository) =>
|
|
new(repository, NullLogger<AlarmWatchListResolver>.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<AlarmSubtagTarget> 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<AlarmSubtagTarget> 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<AlarmSubtagTarget> 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<AlarmSubtagTarget> 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<AlarmSubtagTarget> 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<AlarmSubtagTarget> 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<AlarmSubtagTarget> 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<AlarmSubtagTarget> 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<AlarmSubtagTarget> 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<AlarmSubtagTarget> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fix 1: ExcludeAttributes must be ignored when UseGalaxyRepository is false.
|
|
/// A config-only include must survive even when the same path appears in ExcludeAttributes.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ResolveAsync_ExcludeIgnored_WhenGalaxyRepositoryDisabled()
|
|
{
|
|
// Repo is never consulted; only IncludeAttributes matters.
|
|
StubGalaxyRepository repo = new([]);
|
|
|
|
AlarmWatchListResolver resolver = CreateResolver(repo);
|
|
|
|
IReadOnlyList<AlarmSubtagTarget> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fix 1 (GR-on path): ExcludeAttributes still prunes GR rows when
|
|
/// UseGalaxyRepository is true.
|
|
/// </summary>
|
|
[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<AlarmSubtagTarget> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fix 1: A whitespace-only ExcludeAttributes entry must be skipped and must
|
|
/// not accidentally exclude any real reference.
|
|
/// </summary>
|
|
[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<AlarmSubtagTarget> 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);
|
|
}
|
|
|
|
/// <summary>In-memory <see cref="IGalaxyRepository"/> returning a fixed alarm rowset.</summary>
|
|
private sealed class StubGalaxyRepository(List<GalaxyAlarmAttributeRow> rows) : IGalaxyRepository
|
|
{
|
|
/// <summary>Gets the number of times <see cref="GetAlarmAttributesAsync"/> was called.</summary>
|
|
public int GetAlarmAttributesCount { get; private set; }
|
|
|
|
/// <inheritdoc />
|
|
public Task<bool> TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(true);
|
|
|
|
/// <inheritdoc />
|
|
public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default) =>
|
|
Task.FromResult<DateTime?>(null);
|
|
|
|
/// <inheritdoc />
|
|
public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default) =>
|
|
Task.FromResult(new List<GalaxyHierarchyRow>());
|
|
|
|
/// <inheritdoc />
|
|
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default) =>
|
|
Task.FromResult(new List<GalaxyAttributeRow>());
|
|
|
|
/// <inheritdoc />
|
|
public Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default)
|
|
{
|
|
GetAlarmAttributesCount++;
|
|
return Task.FromResult(rows);
|
|
}
|
|
}
|
|
|
|
/// <summary><see cref="IGalaxyRepository"/> whose alarm-attribute query throws.</summary>
|
|
private sealed class ThrowingGalaxyRepository(Exception toThrow) : IGalaxyRepository
|
|
{
|
|
/// <inheritdoc />
|
|
public Task<bool> TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(true);
|
|
|
|
/// <inheritdoc />
|
|
public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default) =>
|
|
Task.FromResult<DateTime?>(null);
|
|
|
|
/// <inheritdoc />
|
|
public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default) =>
|
|
Task.FromResult(new List<GalaxyHierarchyRow>());
|
|
|
|
/// <inheritdoc />
|
|
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default) =>
|
|
Task.FromResult(new List<GalaxyAttributeRow>());
|
|
|
|
/// <inheritdoc />
|
|
public Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default) =>
|
|
Task.FromException<List<GalaxyAlarmAttributeRow>>(toThrow);
|
|
}
|
|
}
|