Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmWatchListResolverTests.cs
T
Joseph Doherty f40409a688
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m16s
ci / java (push) Successful in 2m5s
ci / portable (push) Failing after 5m9s
docs: strip task-tracking IDs from code comments and fill missing XML docs
Tracking IDs are review bookkeeping that rots in shipped source; the rationale they tagged is kept. Comment-only change (CommentChecker 458 -> 16 false positives).
2026-10-07 15:34:32 -04:00

561 lines
27 KiB
C#

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;
/// <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);
/// <summary>
/// Resolves and projects to the target list. These cases assert composition rules, which
/// live in <see cref="AlarmWatchListResolution.Targets"/>; the discovery-provenance fields
/// the resolution also carries are asserted by their own cases below.
/// </summary>
/// <param name="resolver">The resolver under test.</param>
/// <param name="options">Alarm options to resolve against.</param>
/// <param name="cancellationToken">Token to cancel the resolution.</param>
/// <returns>The resolved targets.</returns>
private static async Task<IReadOnlyList<AlarmSubtagTarget>> 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(),
},
};
/// <summary>
/// Verifies Galaxy Repository rows and config includes are unioned, configured
/// excludes are removed, and duplicate references (case-insensitive) collapse
/// to a single entry.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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 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);
}
/// <summary>Verifies subtag item addresses are composed using the configured subtag names.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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 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);
}
/// <summary>Verifies that configuring empty Priority and AckComment subtag names leaves those target fields empty.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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 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);
}
/// <summary>Verifies the canonical full reference uses the row's discovered Galaxy area, not the configured Discovery.Area.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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 ResolveTargetsAsync(resolver,
Options(area: "DEV", defaultArea: "DEV"));
AlarmSubtagTarget target = Assert.Single(result);
Assert.Equal("Galaxy!TestArea.TestMachine_001.TestAlarm001", target.AlarmFullReference);
}
/// <summary>Verifies the canonical full reference omits the area segment when neither a discovered nor a configured area is present.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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 ResolveTargetsAsync(resolver, Options());
AlarmSubtagTarget target = Assert.Single(result);
Assert.Equal("Galaxy!Tank01.Level.HiHi", target.AlarmFullReference);
}
/// <summary>Verifies a config include entry with no discovered area falls back to the configured Discovery.Area.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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 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);
}
/// <summary>Verifies a config include entry falls back to DefaultArea when Discovery.Area is empty.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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 ResolveTargetsAsync(resolver, Options(
area: "",
defaultArea: "Plant",
include: ["Tank01.Level.HiHi"]));
AlarmSubtagTarget target = Assert.Single(result);
Assert.Equal("Galaxy!Plant.Tank01.Level.HiHi", target.AlarmFullReference);
}
/// <summary>Verifies that disabling Galaxy Repository discovery skips the repository call and resolves the watch list from configured includes only.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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 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);
}
/// <summary>Verifies that a repository exception during discovery is logged and swallowed, returning the config-only watch list instead of throwing.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ResolveAsync_RepositoryThrows_LogsAndReturnsConfigOnlySet()
{
ThrowingGalaxyRepository repo = new(new InvalidOperationException("SQL unavailable"));
AlarmWatchListResolver resolver = CreateResolver(repo);
IReadOnlyList<AlarmSubtagTarget> 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);
}
/// <summary>
/// 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.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>
/// 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.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies the source object reference is derived from a config include entry, using the portion before the first dot when present.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ResolveAsync_DerivesSourceObjectForConfigEntry()
{
StubGalaxyRepository repo = new([]);
AlarmWatchListResolver resolver = CreateResolver(repo);
IReadOnlyList<AlarmSubtagTarget> 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);
}
/// <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>
/// <returns>A task that represents the asynchronous operation.</returns>
[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 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);
}
/// <summary>
/// Fix 1 (GR-on path): ExcludeAttributes still prunes GR rows when
/// UseGalaxyRepository is true.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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 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);
}
/// <summary>
/// Fix 1: A whitespace-only ExcludeAttributes entry must be skipped and must
/// not accidentally exclude any real reference.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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 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);
}
/// <summary>
/// A cancellation triggered while Galaxy Repository discovery is
/// awaiting must propagate as <see cref="OperationCanceledException"/>, not be
/// swallowed into a config-only watch-list, per the <see cref="IAlarmWatchListResolver"/>
/// contract.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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<OperationCanceledException>(() =>
ResolveTargetsAsync(resolver,
Options(include: ["Tank01.Level.HiHi"]),
cts.Token));
}
/// <summary>
/// An entry that appears in both
/// <c>IncludeAttributes</c> and <c>ExcludeAttributes</c> is removed — excludes
/// win over explicit includes (the documented "excludes also suppress matching
/// explicit includes" behaviour).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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<AlarmSubtagTarget> 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));
}
/// <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; }
/// <summary>Always reports a successful connection.</summary>
/// <param name="ct">Token to observe for cancellation.</param>
/// <returns>A completed task carrying <see langword="true"/>.</returns>
public Task<bool> TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(true);
/// <summary>Reports no known deploy time.</summary>
/// <param name="ct">Token to observe for cancellation.</param>
/// <returns>A completed task carrying <see langword="null"/>.</returns>
public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default) =>
Task.FromResult<DateTime?>(null);
/// <summary>Not exercised by these tests; returns an empty hierarchy.</summary>
/// <param name="ct">Token to observe for cancellation.</param>
/// <returns>A completed task carrying an empty list.</returns>
public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default) =>
Task.FromResult(new List<GalaxyHierarchyRow>());
/// <summary>Not exercised by these tests; returns an empty attribute set.</summary>
/// <param name="ct">Token to observe for cancellation.</param>
/// <returns>A completed task carrying an empty list.</returns>
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default) =>
Task.FromResult(new List<GalaxyAttributeRow>());
/// <summary>Returns the fixed alarm rowset supplied at construction, tracking how many times it was called.</summary>
/// <param name="ct">Token to observe for cancellation.</param>
/// <returns>A completed task carrying the configured alarm attribute rows.</returns>
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
{
/// <summary>Always reports a successful connection; only alarm-attribute discovery fails for this fake.</summary>
/// <param name="ct">Token to observe for cancellation.</param>
/// <returns>A completed task carrying <see langword="true"/>.</returns>
public Task<bool> TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(true);
/// <summary>Reports no known deploy time.</summary>
/// <param name="ct">Token to observe for cancellation.</param>
/// <returns>A completed task carrying <see langword="null"/>.</returns>
public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default) =>
Task.FromResult<DateTime?>(null);
/// <summary>Not exercised by these tests; returns an empty hierarchy.</summary>
/// <param name="ct">Token to observe for cancellation.</param>
/// <returns>A completed task carrying an empty list.</returns>
public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default) =>
Task.FromResult(new List<GalaxyHierarchyRow>());
/// <summary>Not exercised by these tests; returns an empty attribute set.</summary>
/// <param name="ct">Token to observe for cancellation.</param>
/// <returns>A completed task carrying an empty list.</returns>
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default) =>
Task.FromResult(new List<GalaxyAttributeRow>());
/// <summary>Simulates the live SQL path failing by faulting with the configured exception.</summary>
/// <param name="ct">Token to observe for cancellation.</param>
/// <returns>A faulted task carrying the configured exception.</returns>
public Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default) =>
Task.FromException<List<GalaxyAlarmAttributeRow>>(toThrow);
}
/// <summary>
/// <see cref="IGalaxyRepository"/> whose alarm-attribute query cancels the
/// supplied source and throws a token-bound <see cref="OperationCanceledException"/>,
/// mirroring the live SQL path being cancelled mid-await.
/// </summary>
private sealed class CancellingGalaxyRepository(CancellationTokenSource source) : IGalaxyRepository
{
/// <summary>Always reports a successful connection; only alarm-attribute discovery cancels for this fake.</summary>
/// <param name="ct">Token to observe for cancellation.</param>
/// <returns>A completed task carrying <see langword="true"/>.</returns>
public Task<bool> TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(true);
/// <summary>Reports no known deploy time.</summary>
/// <param name="ct">Token to observe for cancellation.</param>
/// <returns>A completed task carrying <see langword="null"/>.</returns>
public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default) =>
Task.FromResult<DateTime?>(null);
/// <summary>Not exercised by these tests; returns an empty hierarchy.</summary>
/// <param name="ct">Token to observe for cancellation.</param>
/// <returns>A completed task carrying an empty list.</returns>
public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default) =>
Task.FromResult(new List<GalaxyHierarchyRow>());
/// <summary>Not exercised by these tests; returns an empty attribute set.</summary>
/// <param name="ct">Token to observe for cancellation.</param>
/// <returns>A completed task carrying an empty list.</returns>
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default) =>
Task.FromResult(new List<GalaxyAttributeRow>());
/// <summary>Cancels the supplied source and throws, mirroring the live SQL path being cancelled mid-await.</summary>
/// <param name="ct">Token to observe for cancellation.</param>
/// <returns>Never returns normally; always throws <see cref="OperationCanceledException"/>.</returns>
public Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default)
{
source.Cancel();
ct.ThrowIfCancellationRequested();
return Task.FromResult(new List<GalaxyAlarmAttributeRow>());
}
}
}