Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/MxGatewayDataConnectionAlarmReconnectTests.cs
T
Joseph Doherty 513fa1aa16 docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)
Comment/XML-doc changes only:
- add summaries to ~10k test methods, fixtures, fakes and helpers
  (+ <param> on theories, <returns> on async Task methods,
  <inheritdoc/> on interface implementations the checker can resolve)
- strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N,
  #NN, Component-0NN) from comments, keeping the explanatory text;
  reword false positives in comments only
- real docs on fakes implementing BCL/library interfaces
  (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime)
- reword the last 5 src/ false positives (CWE-1236, failures-1,
  year-0001, M365)

Only non-comment edits: four one-line nested classes split across lines
so members can carry docs. Build 0 errors / 0 warnings.
2026-10-07 15:05:49 -04:00

254 lines
14 KiB
C#

using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
/// <summary>
/// Regression tests for alarm-stream state on reconnect. Before the fix, <c>ConnectAsync</c> reset the
/// event loop and the client but left the alarm-stream state (<c>_alarmCts</c>,
/// <c>_alarmSubCount</c>, <c>_alarmSubSources</c>, <c>_alarmStreamPrefix</c>) untouched.
/// After a reconnect, the DataConnectionActor's transparent re-subscribe
/// (<c>ReSubscribeAllAlarms</c>) called <c>SubscribeAlarmsAsync</c> again, but the stale
/// non-null <c>_alarmCts</c> plus the coverage check made it short-circuit and return
/// success WITHOUT ever opening a new stream on the new client — leaving the orphaned
/// old stream loop retrying the disposed old client forever and the native-alarm mirror
/// frozen until failover/redeploy.
/// </summary>
[Collection("DataConnectionManagerActor")]
public class MxGatewayDataConnectionAlarmReconnectTests
{
private static Dictionary<string, string> Details() => new()
{
["Endpoint"] = "http://gw:5000",
["ApiKey"] = "key",
};
/// <summary>Verifies that each transport refresh has its own connection-wide epoch.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DC5_EachTransportRefreshHasItsOwnConnectionWideEpoch()
{
var clients = new List<PerInstanceFakeMxGatewayClient>();
var adapter = new MxGatewayDataConnection(new RecordingFactory(clients), NullLogger<MxGatewayDataConnection>.Instance);
var received = new System.Collections.Concurrent.ConcurrentQueue<NativeAlarmTransition>();
await adapter.ConnectAsync(Details());
await adapter.SubscribeAlarmsAsync("Area1", null, received.Enqueue);
await WaitUntilAsync(() => clients[0].AlarmStreamOpens.Count == 1);
clients[0].EmitAlarm(MakeTransition(AlarmTransitionKind.SnapshotComplete));
clients[0].EmitAlarm(MakeTransition(AlarmTransitionKind.SnapshotStart));
clients[0].EmitAlarm(MakeTransition(AlarmTransitionKind.SnapshotComplete));
var starts = received.Where(t => t.Kind == AlarmTransitionKind.SnapshotStart).ToArray();
var ends = received.Where(t => t.Kind == AlarmTransitionKind.SnapshotComplete).ToArray();
Assert.Equal(2, starts.Length);
Assert.Equal(2, ends.Length);
Assert.NotNull(starts[0].SnapshotId);
Assert.NotEqual(starts[0].SnapshotId, starts[1].SnapshotId);
Assert.Equal(starts[0].SnapshotId, ends[0].SnapshotId);
Assert.Equal(starts[1].SnapshotId, ends[1].SnapshotId);
Assert.All(starts, t => Assert.Null(t.SnapshotSourceReference));
await adapter.DisconnectAsync();
}
/// <summary>Verifies that a newly covered source requests a fresh standing snapshot.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DC5_CoveredNewSourceRequestsFreshStandingSnapshot()
{
var clients = new List<PerInstanceFakeMxGatewayClient>();
var adapter = new MxGatewayDataConnection(new RecordingFactory(clients), NullLogger<MxGatewayDataConnection>.Instance);
await adapter.ConnectAsync(Details());
await adapter.SubscribeAlarmsAsync("Area1", null, _ => { });
await WaitUntilAsync(() => clients[0].AlarmStreamOpens.Count == 1);
await adapter.SubscribeAlarmsAsync("Area1.Tank1", null, _ => { });
await WaitUntilAsync(() => clients[0].AlarmStreamOpens.Count == 2);
Assert.True(clients[0].AlarmStreamTokens[0].IsCancellationRequested);
await adapter.DisconnectAsync();
}
/// <summary>Verifies that reconnecting and then subscribing to alarms opens a new stream on the new client.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reconnect_ThenSubscribeAlarms_OpensNewStreamOnNewClient()
{
var clients = new List<PerInstanceFakeMxGatewayClient>();
var factory = new RecordingFactory(clients);
var adapter = new MxGatewayDataConnection(factory, NullLogger<MxGatewayDataConnection>.Instance);
await adapter.ConnectAsync(Details());
await adapter.SubscribeAlarmsAsync("Area1.Tank1", null, _ => { });
await WaitUntilAsync(() => clients[0].AlarmStreamOpens.Count == 1);
// Reconnect (same source set as before — the coverage check would previously
// treat this as already-covered and never touch the new client at all).
await adapter.ConnectAsync(Details());
Assert.Equal(2, clients.Count);
await adapter.SubscribeAlarmsAsync("Area1.Tank1", null, _ => { });
await WaitUntilAsync(() => clients[1].AlarmStreamOpens.Count == 1);
Assert.Equal("Area1.Tank1", clients[1].AlarmStreamOpens[0]);
// The OLD client must never see a second alarm-stream open after reconnect.
Assert.Single(clients[0].AlarmStreamOpens);
}
/// <summary>Verifies that a reconnect cancels the old alarm stream's token.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reconnect_CancelsTheOldAlarmStreamToken()
{
var clients = new List<PerInstanceFakeMxGatewayClient>();
var factory = new RecordingFactory(clients);
var adapter = new MxGatewayDataConnection(factory, NullLogger<MxGatewayDataConnection>.Instance);
await adapter.ConnectAsync(Details());
await adapter.SubscribeAlarmsAsync("Area1.Tank1", null, _ => { });
await WaitUntilAsync(() => clients[0].AlarmStreamTokens.Count == 1);
var oldStreamToken = clients[0].AlarmStreamTokens[0];
Assert.False(oldStreamToken.IsCancellationRequested);
await adapter.ConnectAsync(Details()); // reconnect
// The token the OLD RunAlarmStreamAsync loop was given must now be cancelled —
// that is what stops it retrying against the disposed old client every 5s.
Assert.True(oldStreamToken.IsCancellationRequested);
}
/// <summary>Verifies that alarm events flow again on the new client after a reconnect.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reconnect_AlarmEventsFlowAgainOnTheNewClient()
{
var clients = new List<PerInstanceFakeMxGatewayClient>();
var factory = new RecordingFactory(clients);
var adapter = new MxGatewayDataConnection(factory, NullLogger<MxGatewayDataConnection>.Instance);
var received = new List<NativeAlarmTransition>();
await adapter.ConnectAsync(Details());
await adapter.SubscribeAlarmsAsync("Area1.Tank1", null, t => { if (t.Kind != AlarmTransitionKind.SnapshotStart) received.Add(t); });
await WaitUntilAsync(() => clients[0].AlarmStreamOpens.Count == 1);
// Prove the feed works pre-reconnect.
clients[0].EmitAlarm(MakeTransition(AlarmTransitionKind.Snapshot));
await WaitUntilAsync(() => received.Count == 1);
await adapter.ConnectAsync(Details()); // reconnect
await adapter.SubscribeAlarmsAsync("Area1.Tank1", null, t => { if (t.Kind != AlarmTransitionKind.SnapshotStart) received.Add(t); });
await WaitUntilAsync(() => clients[1].AlarmStreamOpens.Count == 1);
// A transition on the OLD (disposed, orphaned) client must never reach the
// callback again — only the NEW client's feed is live post-reconnect.
clients[0].EmitAlarm(MakeTransition(AlarmTransitionKind.Raise));
await Task.Delay(100);
Assert.Single(received);
clients[1].EmitAlarm(MakeTransition(AlarmTransitionKind.Snapshot));
await WaitUntilAsync(() => received.Count == 2);
}
private static NativeAlarmTransition MakeTransition(AlarmTransitionKind kind) => new(
"Area1.Tank1.HiAlarm", "Area1.Tank1", "HiAlarm", kind,
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 400),
"Area", "", "", "", "", null, DateTimeOffset.UtcNow, "", "");
private static async Task WaitUntilAsync(Func<bool> condition)
{
for (var i = 0; i < 100 && !condition(); i++)
await Task.Delay(50);
Assert.True(condition(), "condition not met within timeout");
}
/// <summary>Builds a NEW client instance per <c>Create()</c> call, mirroring the real
/// factory and <see cref="MxGatewayDataConnectionReconnectTests"/>'s NSubstitute-based
/// setup — needed here (unlike <c>FakeMxGatewayClient</c>) because the test must tell
/// the pre- and post-reconnect clients apart.</summary>
private sealed class RecordingFactory : IMxGatewayClientFactory
{
private readonly List<PerInstanceFakeMxGatewayClient> _clients;
/// <summary>Initializes a new instance of the <see cref="RecordingFactory"/> class.</summary>
/// <param name="clients">The list that collects every client the factory creates.</param>
public RecordingFactory(List<PerInstanceFakeMxGatewayClient> clients) => _clients = clients;
/// <inheritdoc />
public IMxGatewayClient Create()
{
var c = new PerInstanceFakeMxGatewayClient();
_clients.Add(c);
return c;
}
}
/// <summary>Minimal per-instance fake — only the members this test exercises are
/// implemented meaningfully; the rest are unused no-ops for the connect/alarm-only
/// path under test.</summary>
private sealed class PerInstanceFakeMxGatewayClient : IMxGatewayClient
{
public readonly List<string?> AlarmStreamOpens = new();
public readonly List<CancellationToken> AlarmStreamTokens = new();
private Action<NativeAlarmTransition>? _onTransition;
private CancellationToken _streamToken;
/// <inheritdoc />
public Task ConnectAsync(MxGatewayConnectionOptions o, CancellationToken ct = default) => Task.CompletedTask;
/// <inheritdoc />
public Task DisconnectAsync(CancellationToken ct = default) => Task.CompletedTask;
/// <inheritdoc />
public Task<string> SubscribeAsync(string tagPath, CancellationToken ct = default) => Task.FromResult(Guid.NewGuid().ToString());
/// <inheritdoc />
public Task UnsubscribeAsync(string subscriptionId, CancellationToken ct = default) => Task.CompletedTask;
/// <inheritdoc />
public Task<IReadOnlyList<MxSubscribeOutcome>> SubscribeBulkAsync(IReadOnlyList<string> tagPaths, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<MxSubscribeOutcome>>(Array.Empty<MxSubscribeOutcome>());
/// <inheritdoc />
public Task UnsubscribeBulkAsync(IReadOnlyList<string> subscriptionIds, CancellationToken ct = default) => Task.CompletedTask;
/// <inheritdoc />
public Task<IReadOnlyList<MxReadOutcome>> ReadAsync(IReadOnlyList<string> tagPaths, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<MxReadOutcome>>(Array.Empty<MxReadOutcome>());
/// <inheritdoc />
public Task<IReadOnlyList<MxWriteOutcome>> WriteAsync(IReadOnlyList<(string TagPath, object? Value)> writes, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<MxWriteOutcome>>(Array.Empty<MxWriteOutcome>());
/// <inheritdoc />
public Task<(IReadOnlyList<MxBrowseChild> Children, bool Truncated)> BrowseChildrenAsync(string? parentNodeId, CancellationToken ct = default)
=> Task.FromResult<(IReadOnlyList<MxBrowseChild>, bool)>((Array.Empty<MxBrowseChild>(), false));
/// <inheritdoc />
public Task RunEventLoopAsync(Action<MxValueUpdate> onUpdate, CancellationToken ct = default)
=> Task.Delay(Timeout.Infinite, ct).ContinueWith(_ => { }, TaskScheduler.Default);
/// <inheritdoc />
public Task RunAlarmStreamAsync(string? alarmFilterPrefix, Action<NativeAlarmTransition> onTransition, CancellationToken ct = default)
{
// Mirrors the real RunAlarmStreamAsync seam: records the open, then just
// parks on the token like the live gateway stream would between events —
// the adapter-level bug under test is about which CLIENT/TOKEN the loop was
// started on, not the internal 5s retry loop inside RealMxGatewayClient.
lock (AlarmStreamOpens)
{
AlarmStreamOpens.Add(alarmFilterPrefix);
AlarmStreamTokens.Add(ct);
}
_onTransition = onTransition;
_streamToken = ct;
return Task.Delay(Timeout.Infinite, ct).ContinueWith(_ => { }, TaskScheduler.Default);
}
/// <summary>Does nothing; the fake holds no resources.</summary>
/// <returns>A completed task.</returns>
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
/// <summary>Simulates a transition arriving on this client's live stream. Gated on
/// the stream token exactly like the real gateway stream would be — once cancelled,
/// no more events are delivered — so a test can prove the OLD client's feed is
/// genuinely dead post-reconnect rather than just unobserved.</summary>
/// <param name="transition">The alarm transition to deliver.</param>
public void EmitAlarm(NativeAlarmTransition transition)
{
if (_streamToken.IsCancellationRequested)
return;
_onTransition?.Invoke(transition);
}
}
}