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.
This commit is contained in:
+18
-1
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// WP2.1b — the primitives the batch seam is built from, tested in isolation because the
|
||||
/// The primitives the batch seam is built from, tested in isolation because the
|
||||
/// OPC Foundation Session/Subscription types they drive cannot be faked without a live
|
||||
/// server: monitored-item shard placement, the apply-serialization gate, the bounded
|
||||
/// pipeline behind the MxGateway supervisory advise, the alarm-stream union prefix, and
|
||||
@@ -14,6 +14,7 @@ public class BatchSeamPrimitiveTests
|
||||
{
|
||||
// ── Shard placement ──
|
||||
|
||||
/// <summary>Verifies that sharding, splits items at the budget.</summary>
|
||||
[Fact]
|
||||
public void Sharding_SplitsItemsAtTheBudget()
|
||||
{
|
||||
@@ -31,6 +32,7 @@ public class BatchSeamPrimitiveTests
|
||||
Assert.Equal(2, placement[12_000]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that sharding, fills the first shard with free capacity before creating one.</summary>
|
||||
[Fact]
|
||||
public void Sharding_FillsTheFirstShardWithFreeCapacityBeforeCreatingOne()
|
||||
{
|
||||
@@ -41,6 +43,7 @@ public class BatchSeamPrimitiveTests
|
||||
Assert.Equal([1, 2], placement);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that sharding, non positive budget degrades to one item per shard.</summary>
|
||||
[Fact]
|
||||
public void Sharding_NonPositiveBudgetDegradesToOneItemPerShard()
|
||||
{
|
||||
@@ -49,6 +52,8 @@ public class BatchSeamPrimitiveTests
|
||||
|
||||
// ── Apply serialization ──
|
||||
|
||||
/// <summary>Verifies that apply gate, never runs two sections concurrently.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ApplyGate_NeverRunsTwoSectionsConcurrently()
|
||||
{
|
||||
@@ -69,6 +74,8 @@ public class BatchSeamPrimitiveTests
|
||||
Assert.Equal(1, maxObserved);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that apply gate, releases when a section throws.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ApplyGate_ReleasesWhenASectionThrows()
|
||||
{
|
||||
@@ -90,6 +97,8 @@ public class BatchSeamPrimitiveTests
|
||||
|
||||
// ── Bounded pipeline (MxGateway supervisory advise) ──
|
||||
|
||||
/// <summary>Verifies that bulk pipeline, keeps in flight count within the window.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task BulkPipeline_KeepsInFlightCountWithinTheWindow()
|
||||
{
|
||||
@@ -116,6 +125,8 @@ public class BatchSeamPrimitiveTests
|
||||
Assert.InRange(maxObserved, 1, parallelism);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that bulk pipeline, captures per item faults without aborting the batch.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task BulkPipeline_CapturesPerItemFaultsWithoutAbortingTheBatch()
|
||||
{
|
||||
@@ -132,6 +143,9 @@ public class BatchSeamPrimitiveTests
|
||||
|
||||
// ── Alarm-stream union prefix ──
|
||||
|
||||
/// <summary>Verifies that alarm prefix, is the longest common prefix.</summary>
|
||||
/// <param name="sources">Test case input.</param>
|
||||
/// <param name="expected">Test case input.</param>
|
||||
[Theory]
|
||||
[InlineData(new[] { "Area1.Tank1", "Area1.Tank2" }, "Area1.Tank")]
|
||||
[InlineData(new[] { "Area1.Tank1" }, "Area1.Tank1")]
|
||||
@@ -144,6 +158,7 @@ public class BatchSeamPrimitiveTests
|
||||
Assert.Equal(expected, AlarmFilterPrefix.LongestCommonPrefix(sources));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that alarm prefix, covers only sources under the live prefix.</summary>
|
||||
[Fact]
|
||||
public void AlarmPrefix_CoversOnlySourcesUnderTheLivePrefix()
|
||||
{
|
||||
@@ -155,6 +170,7 @@ public class BatchSeamPrimitiveTests
|
||||
|
||||
// ── Tag-resolution backoff ──
|
||||
|
||||
/// <summary>Verifies that backoff, doubles per failed round and caps at the maximum.</summary>
|
||||
[Fact]
|
||||
public void Backoff_DoublesPerFailedRoundAndCapsAtTheMaximum()
|
||||
{
|
||||
@@ -182,6 +198,7 @@ public class BatchSeamPrimitiveTests
|
||||
], sequence);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that backoff, misconfigured ceiling below floor degrades to a fixed interval.</summary>
|
||||
[Fact]
|
||||
public void Backoff_MisconfiguredCeilingBelowFloorDegradesToAFixedInterval()
|
||||
{
|
||||
|
||||
@@ -26,16 +26,20 @@ public sealed class FakeMxGatewayClient : IMxGatewayClient, IMxGatewayClientFact
|
||||
public Func<string?, (IReadOnlyList<MxBrowseChild>, bool)>? BrowseHandler;
|
||||
private int _nextHandle;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IMxGatewayClient Create() => this;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ConnectAsync(MxGatewayConnectionOptions o, CancellationToken ct = default)
|
||||
{
|
||||
ConnectedWith = o;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DisconnectAsync(CancellationToken ct = default) => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string> SubscribeAsync(string tag, CancellationToken ct = default)
|
||||
{
|
||||
var id = (++_nextHandle).ToString();
|
||||
@@ -43,12 +47,14 @@ public sealed class FakeMxGatewayClient : IMxGatewayClient, IMxGatewayClientFact
|
||||
return Task.FromResult(id);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UnsubscribeAsync(string id, CancellationToken ct = default)
|
||||
{
|
||||
Unsubscribed.Add(id);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<MxSubscribeOutcome>> SubscribeBulkAsync(
|
||||
IReadOnlyList<string> tagPaths, CancellationToken ct = default)
|
||||
{
|
||||
@@ -70,6 +76,7 @@ public sealed class FakeMxGatewayClient : IMxGatewayClient, IMxGatewayClientFact
|
||||
return Task.FromResult<IReadOnlyList<MxSubscribeOutcome>>(outcomes);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UnsubscribeBulkAsync(IReadOnlyList<string> subscriptionIds, CancellationToken ct = default)
|
||||
{
|
||||
BulkUnsubscribeCalls.Add(subscriptionIds.ToList());
|
||||
@@ -77,15 +84,19 @@ public sealed class FakeMxGatewayClient : IMxGatewayClient, IMxGatewayClientFact
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<MxReadOutcome>> ReadAsync(IReadOnlyList<string> tags, CancellationToken ct = default)
|
||||
=> Task.FromResult(ReadHandler!(tags));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<MxWriteOutcome>> WriteAsync(IReadOnlyList<(string TagPath, object? Value)> w, CancellationToken ct = default)
|
||||
=> Task.FromResult(WriteHandler!(w));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<(IReadOnlyList<MxBrowseChild> Children, bool Truncated)> BrowseChildrenAsync(string? p, CancellationToken ct = default)
|
||||
=> Task.FromResult(BrowseHandler!(p));
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RunEventLoopAsync(Action<MxValueUpdate> onUpdate, CancellationToken ct = default)
|
||||
{
|
||||
OnUpdate = onUpdate;
|
||||
@@ -94,6 +105,7 @@ public sealed class FakeMxGatewayClient : IMxGatewayClient, IMxGatewayClientFact
|
||||
ct.ThrowIfCancellationRequested(); // …or FaultEventLoop() faults it to simulate a stream break
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RunAlarmStreamAsync(
|
||||
string? alarmFilterPrefix,
|
||||
Action<ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms.NativeAlarmTransition> onTransition,
|
||||
@@ -106,6 +118,8 @@ public sealed class FakeMxGatewayClient : IMxGatewayClient, IMxGatewayClientFact
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Releases the resources used by this instance asynchronously.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
/// <summary>Simulate a stream break so the adapter raises Disconnected.</summary>
|
||||
|
||||
+14
-1
@@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// WP2.1b — the MxGateway adapter's batch subscribe seam and the alarm-stream union
|
||||
/// The MxGateway adapter's batch subscribe seam and the alarm-stream union
|
||||
/// filter. The plain-vs-supervisory advise choice itself lives inside
|
||||
/// <c>RealMxGatewayClient</c> (it needs a live MXAccess session); its bounded-pipeline
|
||||
/// mechanism is pinned separately by <see cref="BatchSeamPrimitiveTests"/>.
|
||||
@@ -26,6 +26,9 @@ public class MxGatewayBatchSeamTests
|
||||
["ReadTimeoutMs"] = "2000",
|
||||
};
|
||||
|
||||
/// <summary>Verifies that unsubscribing an old handle preserves routing for the new handle.</summary>
|
||||
/// <param name="batch">Whether the batch subscribe path is used instead of the single-tag path.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
@@ -50,6 +53,8 @@ public class MxGatewayBatchSeamTests
|
||||
while (!condition()) await Task.Delay(10, timeout.Token);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a batch subscribe issues exactly one bulk call per batch.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SubscribeBatch_IssuesExactlyOneBulkCallPerBatch()
|
||||
{
|
||||
@@ -69,6 +74,8 @@ public class MxGatewayBatchSeamTests
|
||||
Assert.All(results, r => Assert.NotNull(r.SubscriptionId));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a batch subscribe reports per-tag failures without failing the batch.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SubscribeBatch_ReportsPerTagFailuresWithoutFailingTheBatch()
|
||||
{
|
||||
@@ -86,6 +93,8 @@ public class MxGatewayBatchSeamTests
|
||||
Assert.NotNull(failed.ErrorMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a batch unsubscribe releases every id in one bulk call.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task UnsubscribeBatch_ReleasesEveryIdInOneBulkCall()
|
||||
{
|
||||
@@ -100,6 +109,8 @@ public class MxGatewayBatchSeamTests
|
||||
Assert.Equal(2, fake.BulkUnsubscribeCalls[0].Count);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the alarm stream uses the longest common prefix and refreshes for every new source.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AlarmStream_UsesLongestCommonPrefix_AndRefreshesEveryNewSource()
|
||||
{
|
||||
@@ -130,6 +141,8 @@ public class MxGatewayBatchSeamTests
|
||||
Assert.Null(fake.AlarmStreamPrefixes[3]); // "" → gateway-wide
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unsubscribing never restarts the alarm stream.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AlarmStream_UnsubscribeNeverRestartsTheStream()
|
||||
{
|
||||
|
||||
+28
-1
@@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// P2-2 (arch-review 2026-08-19, High): before the fix, <c>ConnectAsync</c> reset the
|
||||
/// 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
|
||||
@@ -27,6 +27,8 @@ public class MxGatewayDataConnectionAlarmReconnectTests
|
||||
|
||||
|
||||
|
||||
/// <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()
|
||||
{
|
||||
@@ -51,6 +53,8 @@ public class MxGatewayDataConnectionAlarmReconnectTests
|
||||
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()
|
||||
{
|
||||
@@ -65,6 +69,8 @@ public class MxGatewayDataConnectionAlarmReconnectTests
|
||||
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()
|
||||
{
|
||||
@@ -89,6 +95,8 @@ public class MxGatewayDataConnectionAlarmReconnectTests
|
||||
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()
|
||||
{
|
||||
@@ -109,6 +117,8 @@ public class MxGatewayDataConnectionAlarmReconnectTests
|
||||
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()
|
||||
{
|
||||
@@ -158,8 +168,11 @@ public class MxGatewayDataConnectionAlarmReconnectTests
|
||||
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();
|
||||
@@ -178,22 +191,33 @@ public class MxGatewayDataConnectionAlarmReconnectTests
|
||||
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
|
||||
@@ -210,12 +234,15 @@ public class MxGatewayDataConnectionAlarmReconnectTests
|
||||
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)
|
||||
|
||||
+13
-2
@@ -18,6 +18,8 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
|
||||
[Collection("DataConnectionManagerActor")]
|
||||
public class MxGatewayDataConnectionReconnectTests
|
||||
{
|
||||
/// <summary>Verifies that a reconnect disposes the previous client and cancels the previous event loop.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Reconnect_DisposesPreviousClient_AndCancelsPreviousEventLoop()
|
||||
{
|
||||
@@ -50,6 +52,8 @@ public class MxGatewayDataConnectionReconnectTests
|
||||
Assert.False(loopTokens[1].IsCancellationRequested); // new loop live
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an RPC fault from a stale event loop after a reconnect does not signal a disconnect.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StaleEventLoopRpcFault_AfterReconnect_DoesNotSignalDisconnected()
|
||||
{
|
||||
@@ -61,7 +65,7 @@ public class MxGatewayDataConnectionReconnectTests
|
||||
|
||||
await adapter.ConnectAsync(details);
|
||||
await WaitUntilAsync(() => loopTokens.Count == 1);
|
||||
await adapter.ConnectAsync(details); // reconnect: cancels loop #0, resets _disconnectFired
|
||||
await adapter.ConnectAsync(details); // reconnect: cancels the first loop, resets _disconnectFired
|
||||
await WaitUntilAsync(() => loopTokens.Count == 2);
|
||||
|
||||
// The OLD loop observes its cancellation as a gRPC fault, not an OCE —
|
||||
@@ -75,6 +79,8 @@ public class MxGatewayDataConnectionReconnectTests
|
||||
await WaitUntilAsync(() => Volatile.Read(ref disconnectsRef.Value) == 1);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an object-disposed fault from a stale event loop after a reconnect does not signal a disconnect.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StaleEventLoopObjectDisposedFault_AfterReconnect_DoesNotSignalDisconnected()
|
||||
{
|
||||
@@ -105,6 +111,7 @@ public class MxGatewayDataConnectionReconnectTests
|
||||
/// every write went to the dead session (<c>Session … was not found</c>) for an hour, until
|
||||
/// a process restart. A non-cancelled end of the event stream IS a disconnect.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task EventStreamGracefulCompletion_WhileConnected_SignalsDisconnected()
|
||||
{
|
||||
@@ -121,6 +128,8 @@ public class MxGatewayDataConnectionReconnectTests
|
||||
Assert.Equal(ConnectionHealth.Disconnected, adapter.Status);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that graceful completion of a superseded event stream does not signal a disconnect.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task EventStreamGracefulCompletion_OfSupersededLoop_DoesNotSignalDisconnected()
|
||||
{
|
||||
@@ -131,7 +140,7 @@ public class MxGatewayDataConnectionReconnectTests
|
||||
|
||||
await adapter.ConnectAsync(details);
|
||||
await WaitUntilAsync(() => loopTokens.Count == 1);
|
||||
await adapter.ConnectAsync(details); // reconnect: loop #0 is cancelled
|
||||
await adapter.ConnectAsync(details); // reconnect: the first loop is cancelled
|
||||
await WaitUntilAsync(() => loopTokens.Count == 2);
|
||||
|
||||
loops[0].SetResult(); // the OLD loop winds down normally after its cancel
|
||||
@@ -139,6 +148,8 @@ public class MxGatewayDataConnectionReconnectTests
|
||||
Assert.Equal(0, Volatile.Read(ref disconnectsRef.Value)); // must not flap the new session
|
||||
}
|
||||
|
||||
/// <summary>Verifies that graceful completion of the event stream after an explicit disconnect does not signal a disconnect.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task EventStreamGracefulCompletion_AfterDisconnectAsync_DoesNotSignalDisconnected()
|
||||
{
|
||||
|
||||
+24
-2
@@ -8,7 +8,7 @@ using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
|
||||
|
||||
// G-3 / Task 9: the MxGateway per-endpoint ApiKey may hold a `secret:<name>` reference
|
||||
// The MxGateway per-endpoint ApiKey may hold a `secret:<name>` reference
|
||||
// resolved via ISecretResolver at connect time. Literal keys pass through unchanged
|
||||
// (back-compat); an unresolvable / missing secret, or a secret ref with no resolver
|
||||
// available, fails closed (no connection established with an empty/null key).
|
||||
@@ -27,9 +27,16 @@ public class MxGatewayDataConnectionSecretsTests
|
||||
private sealed class FakeSecretResolver : ISecretResolver
|
||||
{
|
||||
private readonly string? _value;
|
||||
/// <summary>Initializes the fake resolver with the value to return.</summary>
|
||||
/// <param name="value">The secret value returned for every lookup, or null to simulate an unresolvable secret.</param>
|
||||
public FakeSecretResolver(string? value) => _value = value;
|
||||
/// <summary>Gets the names of the secrets that were requested.</summary>
|
||||
public List<string> Requested { get; } = new();
|
||||
|
||||
/// <summary>Looks up the secret by name.</summary>
|
||||
/// <param name="name">The secret name.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The secret, or null when none matches.</returns>
|
||||
public Task<string?> GetAsync(SecretName name, CancellationToken ct)
|
||||
{
|
||||
Requested.Add(name.Value);
|
||||
@@ -37,6 +44,8 @@ public class MxGatewayDataConnectionSecretsTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that connecting resolves a secret reference used as the API key.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ConnectAsync_resolves_secret_ref_apikey()
|
||||
{
|
||||
@@ -52,13 +61,16 @@ public class MxGatewayDataConnectionSecretsTests
|
||||
Assert.Contains("mxgateway/testconn/api-key", resolver.Requested);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a padded or case-variant secret reference resolves to the name the scanner ships.</summary>
|
||||
/// <param name="apiKey">The padded or case-variant API key reference.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData("secret:mxgw-key ")]
|
||||
[InlineData(" SECRET: mxgw-key")]
|
||||
[InlineData("Secret:mxgw-key")]
|
||||
public async Task ConnectAsync_resolves_padded_or_case_variant_ref_to_the_name_the_scanner_ships(string apiKey)
|
||||
{
|
||||
// Final review M3: the adapter and central's SecretReferenceScanner share one parser,
|
||||
// The adapter and central's SecretReferenceScanner share one parser,
|
||||
// so the name looked up is exactly the name that was deployed.
|
||||
var shipped = Assert.Single(ZB.MOM.WW.ScadaBridge.Commons.Types.Secrets.SecretReferenceScanner.Scan([
|
||||
new ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts.DataConnectionArtifact(
|
||||
@@ -76,6 +88,8 @@ public class MxGatewayDataConnectionSecretsTests
|
||||
Assert.Equal("resolved-plaintext-key", fake.ConnectedWith!.ApiKey);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that connecting fails closed on a secret reference with no name.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ConnectAsync_fails_closed_on_a_secret_ref_with_no_name()
|
||||
{
|
||||
@@ -89,6 +103,8 @@ public class MxGatewayDataConnectionSecretsTests
|
||||
Assert.Empty(resolver.Requested);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a literal API key passes through unchanged and the resolver is not called.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ConnectAsync_passes_literal_apikey_unchanged_and_does_not_call_resolver()
|
||||
{
|
||||
@@ -102,6 +118,8 @@ public class MxGatewayDataConnectionSecretsTests
|
||||
Assert.Empty(resolver.Requested);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that connecting fails closed when the secret cannot be resolved.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ConnectAsync_fails_closed_when_secret_unresolvable()
|
||||
{
|
||||
@@ -117,6 +135,8 @@ public class MxGatewayDataConnectionSecretsTests
|
||||
Assert.NotEqual(ConnectionHealth.Connected, adapter.Status);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that connecting fails closed on a secret reference when no resolver is available.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ConnectAsync_fails_closed_on_secret_ref_with_no_resolver()
|
||||
{
|
||||
@@ -140,6 +160,8 @@ public class MxGatewayDataConnectionSecretsTests
|
||||
// 3-arg DataConnectionFactory ctor picked up the DI-registered resolver and threaded it in.
|
||||
// Had it been null, ConnectAsync would have thrown the "no ISecretResolver" fail-closed
|
||||
// path instead and the resolver would never have been called.
|
||||
/// <summary>Verifies that the factory resolved from DI threads the registered secret resolver into the MxGateway adapter.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Factory_from_DI_threads_registered_ISecretResolver_into_MxGateway_adapter()
|
||||
{
|
||||
|
||||
+31
-5
@@ -25,8 +25,10 @@ public class MxGatewayDataConnectionTests
|
||||
["ReadTimeoutMs"] = "2000",
|
||||
};
|
||||
|
||||
// ── Task 6: connect / status / Disconnected ──
|
||||
// ── connect / status / Disconnected ──
|
||||
|
||||
/// <summary>Verifies that connecting resolves the options and sets the status to connected.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ConnectAsync_resolves_options_and_sets_status_connected()
|
||||
{
|
||||
@@ -42,6 +44,8 @@ public class MxGatewayDataConnectionTests
|
||||
Assert.Equal(7, fake.ConnectedWith.WriteUserId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that connecting with a blank client name defaults it to scadabridge.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ConnectAsync_blank_client_name_defaults_to_scadabridge()
|
||||
{
|
||||
@@ -55,6 +59,8 @@ public class MxGatewayDataConnectionTests
|
||||
Assert.Equal("scadabridge", fake.ConnectedWith!.ClientName);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the Disconnected event fires exactly once when the event loop faults.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Disconnected_fires_exactly_once_when_event_loop_faults()
|
||||
{
|
||||
@@ -74,8 +80,10 @@ public class MxGatewayDataConnectionTests
|
||||
Assert.Equal(ConnectionHealth.Disconnected, adapter.Status);
|
||||
}
|
||||
|
||||
// ── Task 7: subscribe / unsubscribe + event routing ──
|
||||
// ── subscribe / unsubscribe + event routing ──
|
||||
|
||||
/// <summary>Verifies that an update to a subscribed tag invokes the callback with a mapped tag value.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Subscribed_tag_update_invokes_callback_with_mapped_TagValue()
|
||||
{
|
||||
@@ -97,6 +105,8 @@ public class MxGatewayDataConnectionTests
|
||||
Assert.Equal(ts, received.Timestamp);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unsubscribing stops routing updates to the callback.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Unsubscribe_stops_routing_updates()
|
||||
{
|
||||
@@ -115,8 +125,10 @@ public class MxGatewayDataConnectionTests
|
||||
Assert.Contains(subId, fake.Unsubscribed);
|
||||
}
|
||||
|
||||
// ── Task 8: read / write + error classification ──
|
||||
// ── read / write + error classification ──
|
||||
|
||||
/// <summary>Verifies that a read maps both success and failure results.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReadAsync_maps_success_and_failure()
|
||||
{
|
||||
@@ -138,6 +150,8 @@ public class MxGatewayDataConnectionTests
|
||||
Assert.Equal("bad tag", bad.ErrorMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a batch read returns a dictionary keyed by tag.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReadBatchAsync_returns_dictionary_keyed_by_tag()
|
||||
{
|
||||
@@ -154,6 +168,8 @@ public class MxGatewayDataConnectionTests
|
||||
Assert.Equal(3, results["bbb"].Value!.Value);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a failed write is mapped to an unsuccessful write result.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteAsync_maps_failure_to_unsuccessful_WriteResult()
|
||||
{
|
||||
@@ -170,8 +186,10 @@ public class MxGatewayDataConnectionTests
|
||||
Assert.Equal("rejected", r.ErrorMessage);
|
||||
}
|
||||
|
||||
// ── Task 9: WriteBatchAndWait ──
|
||||
// ── WriteBatchAndWait ──
|
||||
|
||||
/// <summary>Verifies that a batch write and wait returns true when the response value matches.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteBatchAndWait_returns_true_when_response_matches()
|
||||
{
|
||||
@@ -200,6 +218,8 @@ public class MxGatewayDataConnectionTests
|
||||
Assert.Contains("Flag", writeCalls);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a batch write and wait returns false on timeout.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteBatchAndWait_returns_false_on_timeout()
|
||||
{
|
||||
@@ -219,8 +239,10 @@ public class MxGatewayDataConnectionTests
|
||||
Assert.False(ok);
|
||||
}
|
||||
|
||||
// ── Task 10: browse ──
|
||||
// ── browse ──
|
||||
|
||||
/// <summary>Verifies that browsing children maps the children and the truncated flag.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task BrowseChildrenAsync_maps_children_and_truncated()
|
||||
{
|
||||
@@ -245,6 +267,8 @@ public class MxGatewayDataConnectionTests
|
||||
Assert.Equal(BrowseNodeClass.Variable, result.Children[1].NodeClass);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that browsing children surfaces the attribute data type.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task BrowseChildrenAsync_surfaces_attribute_datatype()
|
||||
{
|
||||
@@ -263,6 +287,8 @@ public class MxGatewayDataConnectionTests
|
||||
Assert.Equal("Double", result.Children[0].DataType);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that browsing children throws when the connection is not established.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task BrowseChildrenAsync_throws_when_not_connected()
|
||||
{
|
||||
|
||||
+3
-1
@@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// Task 9 (opcua-tag-browser): <see cref="OpcUaDataConnection"/> implements
|
||||
/// <see cref="OpcUaDataConnection"/> implements
|
||||
/// <see cref="IBrowsableDataConnection"/> as a one-line forwarder onto the
|
||||
/// underlying <see cref="IOpcUaClient"/>. This guards that wiring — the
|
||||
/// adapter must not add its own browse semantics; all browse behaviour lives
|
||||
@@ -14,6 +14,8 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
|
||||
/// </summary>
|
||||
public class OpcUaDataConnectionBrowseTests
|
||||
{
|
||||
/// <summary>Verifies that browsing children is forwarded to the underlying OPC UA client.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task BrowseChildrenAsync_ForwardsToUnderlyingClient()
|
||||
{
|
||||
|
||||
+14
-2
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #14: stored bindings hard-code a namespace <i>index</i>, which is only
|
||||
/// Stored bindings hard-code a namespace <i>index</i>, which is only
|
||||
/// meaningful against one server's namespace table. These tests pin the durable
|
||||
/// <c>nsu=<uri></c> form and the back-compat of the existing <c>ns=</c> form.
|
||||
/// </summary>
|
||||
@@ -32,6 +32,7 @@ public class OpcUaNodeReferenceTests
|
||||
return t;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the legacy namespace index form still resolves.</summary>
|
||||
[Fact]
|
||||
public void Resolve_IndexForm_StillWorks()
|
||||
{
|
||||
@@ -42,6 +43,7 @@ public class OpcUaNodeReferenceTests
|
||||
Assert.Equal("pymodbus/plc/HR200", nodeId.Identifier);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the namespace URI form resolves to the server's live namespace index.</summary>
|
||||
[Fact]
|
||||
public void Resolve_UriForm_MapsToLiveIndex()
|
||||
{
|
||||
@@ -51,6 +53,7 @@ public class OpcUaNodeReferenceTests
|
||||
Assert.Equal("pymodbus/plc/HR200", nodeId.Identifier);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the namespace URI form tracks the index across servers with different namespace tables.</summary>
|
||||
[Fact]
|
||||
public void Resolve_UriForm_TracksIndexAcrossServers()
|
||||
{
|
||||
@@ -68,10 +71,11 @@ public class OpcUaNodeReferenceTests
|
||||
Assert.Equal(3, moved.NamespaceIndex); // followed the URI, not the index
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the index form silently binds to a different namespace after a server upgrade.</summary>
|
||||
[Fact]
|
||||
public void Resolve_IndexForm_SilentlyBindsToTheWrongNamespace_AcrossServerUpgrade()
|
||||
{
|
||||
// This is the #14 hazard, pinned as a characterisation test rather than a wish:
|
||||
// This is the namespace-index hazard, pinned as a characterisation test rather than a wish:
|
||||
// v2's sole custom namespace and v3's raw namespace BOTH sit at index 2, so a
|
||||
// v2-era ns=2 binding resolves happily against v3 while meaning something else
|
||||
// entirely. Nothing in the index form can detect that — which is the whole
|
||||
@@ -91,6 +95,7 @@ public class OpcUaNodeReferenceTests
|
||||
Assert.Contains("does not publish", ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that resolving an unknown namespace URI throws.</summary>
|
||||
[Fact]
|
||||
public void Resolve_UnknownNamespaceUri_Throws()
|
||||
{
|
||||
@@ -100,6 +105,7 @@ public class OpcUaNodeReferenceTests
|
||||
Assert.Contains("NamespaceArray", ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that resolving a reference to another server throws.</summary>
|
||||
[Fact]
|
||||
public void Resolve_ForeignServerReference_Throws()
|
||||
{
|
||||
@@ -109,6 +115,8 @@ public class OpcUaNodeReferenceTests
|
||||
Assert.Contains("another server", ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unparseable reference throws.</summary>
|
||||
/// <param name="reference">The malformed node reference.</param>
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ;;; not a node id")]
|
||||
@@ -117,6 +125,7 @@ public class OpcUaNodeReferenceTests
|
||||
Assert.ThrowsAny<ArgumentException>(() => OpcUaNodeReference.Resolve(reference, V3Table()));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a server namespace is converted to the durable URI form.</summary>
|
||||
[Fact]
|
||||
public void ToDurable_ServerNamespace_EmitsUriForm()
|
||||
{
|
||||
@@ -127,6 +136,7 @@ public class OpcUaNodeReferenceTests
|
||||
Assert.Equal($"nsu={RawUri};s=pymodbus/plc/HR200", durable);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the standard namespace stays in the short form.</summary>
|
||||
[Fact]
|
||||
public void ToDurable_StandardNamespace_StaysShort()
|
||||
{
|
||||
@@ -136,6 +146,7 @@ public class OpcUaNodeReferenceTests
|
||||
Assert.Equal("i=85", durable);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a durable reference round-trips through resolve.</summary>
|
||||
[Fact]
|
||||
public void ToDurable_RoundTripsThroughResolve()
|
||||
{
|
||||
@@ -151,6 +162,7 @@ public class OpcUaNodeReferenceTests
|
||||
Assert.Equal("Area/Line/Equip/Speed", resolved.Identifier);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a URI missing from the namespace table falls back instead of throwing.</summary>
|
||||
[Fact]
|
||||
public void ToDurable_UriNotInTable_FallsBackRatherThanThrowing()
|
||||
{
|
||||
|
||||
+3
@@ -3,6 +3,8 @@ using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
|
||||
public class OpcUaSecurityDowngradeTests
|
||||
{
|
||||
/// <summary>Verifies that DC4: requested protection never falls back to none.</summary>
|
||||
/// <param name="discoveryFails">The discovery fails.</param>
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
@@ -13,6 +15,7 @@ public class OpcUaSecurityDowngradeTests
|
||||
? throw new Exception("discovery unavailable")
|
||||
: new EndpointDescriptionCollection { new("opc.tcp://test:4840") { SecurityMode = MessageSecurityMode.None } }));
|
||||
}
|
||||
/// <summary>Verifies that DC4: exact requested mode is selected.</summary>
|
||||
[Fact]
|
||||
public void DC4_ExactRequestedModeIsSelected()
|
||||
{
|
||||
|
||||
+16
-2
@@ -5,13 +5,14 @@ using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// M2.4 (#8): the OPC UA EventFilter gains a server-side <see cref="ContentFilter"/>
|
||||
/// The OPC UA EventFilter gains a server-side <see cref="ContentFilter"/>
|
||||
/// WhereClause as a bandwidth optimisation when a condition-type filter is present.
|
||||
/// The client-side gate in DataConnectionActor remains authoritative; these tests
|
||||
/// only pin the filter-shaping. No live server required — pure SDK object building.
|
||||
/// </summary>
|
||||
public class RealOpcUaClientAlarmFilterTests
|
||||
{
|
||||
/// <summary>Verifies that no condition-type filter produces no where clause.</summary>
|
||||
[Fact]
|
||||
public void BuildAlarmEventFilter_NoFilter_HasNoWhereClause()
|
||||
{
|
||||
@@ -20,6 +21,7 @@ public class RealOpcUaClientAlarmFilterTests
|
||||
Assert.Empty(filter.WhereClause.Elements);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that known condition types produce a non-empty where clause.</summary>
|
||||
[Fact]
|
||||
public void BuildAlarmEventFilter_WithKnownTypes_BuildsNonEmptyWhereClause()
|
||||
{
|
||||
@@ -33,6 +35,7 @@ public class RealOpcUaClientAlarmFilterTests
|
||||
Assert.Contains(filter.WhereClause.Elements, e => e.FilterOperator == FilterOperator.Or);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a single known condition type produces a single of-type clause with no OR.</summary>
|
||||
[Fact]
|
||||
public void BuildAlarmEventFilter_SingleKnownType_BuildsSingleOfType_NoOr()
|
||||
{
|
||||
@@ -43,6 +46,7 @@ public class RealOpcUaClientAlarmFilterTests
|
||||
Assert.Equal(FilterOperator.OfType, filter.WhereClause.Elements[0].FilterOperator);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that condition type matching is case-insensitive.</summary>
|
||||
[Fact]
|
||||
public void BuildAlarmEventFilter_TypeMatchingIsCaseInsensitive()
|
||||
{
|
||||
@@ -51,6 +55,7 @@ public class RealOpcUaClientAlarmFilterTests
|
||||
Assert.Single(filter.WhereClause.Elements, e => e.FilterOperator == FilterOperator.OfType);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a filter of only unknown types omits the where clause.</summary>
|
||||
[Fact]
|
||||
public void BuildAlarmEventFilter_AllUnknownTypes_OmitsWhereClause()
|
||||
{
|
||||
@@ -63,6 +68,7 @@ public class RealOpcUaClientAlarmFilterTests
|
||||
Assert.Empty(filter.WhereClause.Elements);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a mix of known and unknown types omits the where clause.</summary>
|
||||
[Fact]
|
||||
public void BuildAlarmEventFilter_MixedKnownAndUnknown_OmitsWhereClause()
|
||||
{
|
||||
@@ -74,10 +80,11 @@ public class RealOpcUaClientAlarmFilterTests
|
||||
Assert.Empty(filter.WhereClause.Elements);
|
||||
}
|
||||
|
||||
// ── SelectClause index alignment (M2.13 / #27) ───────────────────────────
|
||||
// ── SelectClause index alignment ───────────────────────────
|
||||
// CRITICAL: HandleAlarmEvent reads fields[N] by position. Verify new clauses
|
||||
// are APPENDED at indices 13–18 so existing mappings (0–12) are undisturbed.
|
||||
|
||||
/// <summary>Verifies that the alarm event filter has exactly 19 select clauses.</summary>
|
||||
[Fact]
|
||||
public void BuildAlarmEventFilter_HasExactly19SelectClauses()
|
||||
{
|
||||
@@ -88,6 +95,7 @@ public class RealOpcUaClientAlarmFilterTests
|
||||
Assert.Equal(19, filter.SelectClauses.Count);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that select clause 18 is the acknowledgeable-condition acked-state transition time.</summary>
|
||||
[Fact]
|
||||
public void BuildAlarmEventFilter_Index18_IsAcknowledgeableConditionType_AckedState_TransitionTime()
|
||||
{
|
||||
@@ -101,6 +109,7 @@ public class RealOpcUaClientAlarmFilterTests
|
||||
Assert.Equal("TransitionTime", clause.BrowsePath[1].Name);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that select clause 13 is the alarm-condition active-state transition time.</summary>
|
||||
[Fact]
|
||||
public void BuildAlarmEventFilter_Index13_IsAlarmConditionType_ActiveState_TransitionTime()
|
||||
{
|
||||
@@ -113,6 +122,7 @@ public class RealOpcUaClientAlarmFilterTests
|
||||
Assert.Equal("TransitionTime", clause.BrowsePath[1].Name);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that select clause 14 is the limit-alarm high-high limit.</summary>
|
||||
[Fact]
|
||||
public void BuildAlarmEventFilter_Index14_IsLimitAlarmType_HighHighLimit()
|
||||
{
|
||||
@@ -122,6 +132,7 @@ public class RealOpcUaClientAlarmFilterTests
|
||||
Assert.Equal("HighHighLimit", clause.BrowsePath[0].Name);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that select clause 15 is the limit-alarm high limit.</summary>
|
||||
[Fact]
|
||||
public void BuildAlarmEventFilter_Index15_IsLimitAlarmType_HighLimit()
|
||||
{
|
||||
@@ -131,6 +142,7 @@ public class RealOpcUaClientAlarmFilterTests
|
||||
Assert.Equal("HighLimit", clause.BrowsePath[0].Name);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that select clause 16 is the limit-alarm low limit.</summary>
|
||||
[Fact]
|
||||
public void BuildAlarmEventFilter_Index16_IsLimitAlarmType_LowLimit()
|
||||
{
|
||||
@@ -140,6 +152,7 @@ public class RealOpcUaClientAlarmFilterTests
|
||||
Assert.Equal("LowLimit", clause.BrowsePath[0].Name);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that select clause 17 is the limit-alarm low-low limit.</summary>
|
||||
[Fact]
|
||||
public void BuildAlarmEventFilter_Index17_IsLimitAlarmType_LowLowLimit()
|
||||
{
|
||||
@@ -149,6 +162,7 @@ public class RealOpcUaClientAlarmFilterTests
|
||||
Assert.Equal("LowLowLimit", clause.BrowsePath[0].Name);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the existing select clause indices 0 to 12 are unchanged.</summary>
|
||||
[Fact]
|
||||
public void BuildAlarmEventFilter_ExistingIndices0To12_Unchanged()
|
||||
{
|
||||
|
||||
+4
@@ -24,6 +24,7 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
|
||||
[Trait("Category", "Unit")]
|
||||
public class RealOpcUaClientArrayWriteTests
|
||||
{
|
||||
/// <summary>Verifies that an integer list wraps into a variant array without throwing.</summary>
|
||||
[Fact]
|
||||
public void Variant_wraps_int_list_as_array_without_throwing()
|
||||
{
|
||||
@@ -39,6 +40,7 @@ public class RealOpcUaClientArrayWriteTests
|
||||
Assert.Equal(ValueRanks.OneDimension, variant.TypeInfo.ValueRank);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an integer array wraps into a variant without throwing.</summary>
|
||||
[Fact]
|
||||
public void Variant_wraps_int_array_without_throwing()
|
||||
{
|
||||
@@ -52,6 +54,7 @@ public class RealOpcUaClientArrayWriteTests
|
||||
Assert.Equal(ValueRanks.OneDimension, variant.TypeInfo.ValueRank);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a double array wraps into a variant without throwing.</summary>
|
||||
[Fact]
|
||||
public void Variant_wraps_double_array_without_throwing()
|
||||
{
|
||||
@@ -65,6 +68,7 @@ public class RealOpcUaClientArrayWriteTests
|
||||
Assert.Equal(ValueRanks.OneDimension, variant.TypeInfo.ValueRank);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a string array wraps into a variant without throwing.</summary>
|
||||
[Fact]
|
||||
public void Variant_wraps_string_array_without_throwing()
|
||||
{
|
||||
|
||||
+5
-1
@@ -9,7 +9,7 @@ using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// Arch-review 2026-08-19 <b>P2-4</b> (rollback half) — a batch subscribe whose
|
||||
/// Rollback half of the batch-subscribe fix — a batch subscribe whose
|
||||
/// <c>ApplyChanges</c> throws must leave NO residue behind.
|
||||
///
|
||||
/// <para>
|
||||
@@ -70,6 +70,8 @@ public class RealOpcUaClientBatchRollbackTests
|
||||
return (int)shard.GetType().GetProperty("ItemCount")!.GetValue(shard)!;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a throw from ApplyChanges rolls back every partially created monitored item.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ApplyChangesThrow_RollsBackEveryPartiallyCreatedItem()
|
||||
{
|
||||
@@ -92,6 +94,8 @@ public class RealOpcUaClientBatchRollbackTests
|
||||
Assert.Empty(subscription.MonitoredItems);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that repeated failed batches do not grow the adapter's bookkeeping.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RepeatedFailedBatches_DoNotGrowTheAdapterBookkeeping()
|
||||
{
|
||||
|
||||
+4
@@ -28,6 +28,8 @@ public class RealOpcUaClientBrowseTests
|
||||
// existing docker/ and docker-env2/ topologies dial into.
|
||||
private const string EndpointUrl = "opc.tcp://localhost:50000";
|
||||
|
||||
/// <summary>Verifies the case: browse Children, at, root, returns, objects Folder, with, server, node.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[SkippableFact]
|
||||
public async Task BrowseChildren_at_root_returns_ObjectsFolder_with_Server_node()
|
||||
{
|
||||
@@ -60,6 +62,8 @@ public class RealOpcUaClientBrowseTests
|
||||
Assert.Contains(result.Children, n => n.DisplayName == "Server");
|
||||
}
|
||||
|
||||
/// <summary>Verifies the case: browse Children, throws, connection Not Connected, when, session, is, null.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task BrowseChildren_throws_ConnectionNotConnected_when_session_is_null()
|
||||
{
|
||||
|
||||
+11
@@ -18,6 +18,7 @@ public class RealOpcUaClientEndpointRewriteTests
|
||||
return ep.EndpointUrl;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a 0.0.0.0 advertisement is rewritten to the reachable host while preserving the path.</summary>
|
||||
[Fact]
|
||||
public void Rewrites_0_0_0_0_advertisement_to_the_reachable_host_preserving_path()
|
||||
{
|
||||
@@ -26,6 +27,7 @@ public class RealOpcUaClientEndpointRewriteTests
|
||||
Rewrite("opc.tcp://0.0.0.0:4840/OtOpcUa", "opc.tcp://otopcua-dev-site-a-1-1:4840/OtOpcUa"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an internal NAT hostname is rewritten to the reachable host.</summary>
|
||||
[Fact]
|
||||
public void Rewrites_internal_nat_hostname_to_the_reachable_host()
|
||||
{
|
||||
@@ -34,6 +36,7 @@ public class RealOpcUaClientEndpointRewriteTests
|
||||
Rewrite("opc.tcp://be5667fab9da:4840/OtOpcUa", "opc.tcp://plc.example.com:4840/OtOpcUa"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the port is also rewritten when the advertised port differs.</summary>
|
||||
[Fact]
|
||||
public void Rewrites_port_too_when_the_advertised_port_differs()
|
||||
{
|
||||
@@ -42,6 +45,7 @@ public class RealOpcUaClientEndpointRewriteTests
|
||||
Rewrite("opc.tcp://0.0.0.0:4840/OtOpcUa", "opc.tcp://gateway:51210/OtOpcUa"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an already reachable advertisement is left untouched.</summary>
|
||||
[Fact]
|
||||
public void Leaves_an_already_reachable_advertisement_untouched()
|
||||
{
|
||||
@@ -51,6 +55,7 @@ public class RealOpcUaClientEndpointRewriteTests
|
||||
Rewrite("opc.tcp://scadabridge-opcua:50000", "opc.tcp://scadabridge-opcua:50000"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an authority-only advertisement is handled without appending a trailing slash.</summary>
|
||||
[Fact]
|
||||
public void Handles_authority_only_advertisement_without_appending_a_trailing_slash()
|
||||
{
|
||||
@@ -59,6 +64,7 @@ public class RealOpcUaClientEndpointRewriteTests
|
||||
Rewrite("opc.tcp://0.0.0.0:4840", "opc.tcp://host-b:4840"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an IPv6 reachable host is re-wrapped in brackets.</summary>
|
||||
[Fact]
|
||||
public void Re_wraps_an_ipv6_reachable_host_in_brackets()
|
||||
{
|
||||
@@ -67,6 +73,7 @@ public class RealOpcUaClientEndpointRewriteTests
|
||||
Rewrite("opc.tcp://0.0.0.0:4840/OtOpcUa", "opc.tcp://[::1]:4840/OtOpcUa"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that no port is emitted when neither URL carries one.</summary>
|
||||
[Fact]
|
||||
public void Emits_no_port_when_neither_url_carries_one()
|
||||
{
|
||||
@@ -76,6 +83,7 @@ public class RealOpcUaClientEndpointRewriteTests
|
||||
Rewrite("opc.tcp://0.0.0.0/UaServer", "opc.tcp://myhost/UaServer"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the reachable port is used when the advertisement omits one.</summary>
|
||||
[Fact]
|
||||
public void Uses_the_reachable_port_when_the_advertisement_omits_one()
|
||||
{
|
||||
@@ -84,6 +92,8 @@ public class RealOpcUaClientEndpointRewriteTests
|
||||
Rewrite("opc.tcp://0.0.0.0/UaServer", "opc.tcp://myhost:4840/UaServer"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the endpoint is left unchanged when the discovery URL is unusable.</summary>
|
||||
/// <param name="discovery">The discovery.</param>
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
@@ -93,6 +103,7 @@ public class RealOpcUaClientEndpointRewriteTests
|
||||
Assert.Equal("opc.tcp://0.0.0.0:4840/OtOpcUa", Rewrite("opc.tcp://0.0.0.0:4840/OtOpcUa", discovery));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a null endpoint is a no-op.</summary>
|
||||
[Fact]
|
||||
public void Null_endpoint_is_a_no_op()
|
||||
{
|
||||
|
||||
+1
@@ -7,6 +7,7 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
|
||||
|
||||
public class RealOpcUaSnapshotScopeTests
|
||||
{
|
||||
/// <summary>Verifies that refresh boundaries, preserve source and have distinct epochs.</summary>
|
||||
[Fact]
|
||||
public void RefreshBoundaries_PreserveSourceAndHaveDistinctEpochs()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user