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:
Joseph Doherty
2026-10-07 15:05:49 -04:00
parent 0ffd4bdbea
commit 513fa1aa16
998 changed files with 17896 additions and 2395 deletions
@@ -10,7 +10,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors;
/// <summary>
/// WP2.1b — the DataConnectionActor against a batch-capable adapter: chunked subscribe,
/// The DataConnectionActor against a batch-capable adapter: chunked subscribe,
/// chunked/bounded re-subscribe, the seed deadline, batched tag-resolution probes, the
/// partial-failure surface and the coalesced quality push. The existing suite covers the
/// same actor against a NON batch-capable adapter, which keeps the per-tag fallback pinned.
@@ -42,11 +42,12 @@ public class DataConnectionActorBatchTests : TestKit
private static string[] Tags(int count) => Enumerable.Range(1, count).Select(i => $"tag{i}").ToArray();
/// <summary>Verifies that subscribing issues one adapter round trip per chunk rather than one per tag.</summary>
[Fact]
public void Subscribe_IssuesOneAdapterRoundTripPerChunk_NotPerTag()
{
// 12 tags at SubscribeBatchSize 5 → 3 batch calls (5/5/2) and ZERO per-tag
// subscribes. This is finding #3: the old path was one adapter call (and one OPC UA
// subscribes. The old path was one adapter call (and one OPC UA
// ApplyChanges) per tag.
var adapter = new FakeBatchDataConnection();
var actor = CreateActor(adapter, Options(), "batch-chunking");
@@ -60,6 +61,7 @@ public class DataConnectionActorBatchTests : TestKit
Assert.Equal(0, Volatile.Read(ref adapter.SingleSubscribeCalls));
}
/// <summary>Verifies that seeding uses chunked bulk reads rather than per-tag reads.</summary>
[Fact]
public void Seed_UsesChunkedBulkReads_NotPerTagReads()
{
@@ -78,6 +80,7 @@ public class DataConnectionActorBatchTests : TestKit
ExpectMsg<TagValueUpdate>(u => u.Quality == QualityCode.Good, TimeSpan.FromSeconds(5));
}
/// <summary>Verifies that hanging seed reads return at the overall deadline and the subscribe is still acknowledged.</summary>
[Fact]
public void Seed_HangingReads_ReturnAtTheOverallDeadline_AndTheSubscribeIsStillAcked()
{
@@ -102,6 +105,7 @@ public class DataConnectionActorBatchTests : TestKit
ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
/// <summary>Verifies that a per-tag partial failure marks that tag bad while the request still succeeds.</summary>
[Fact]
public void PartialFailure_PerTagRow_MarksTagBad_ButTheRequestStillSucceeds()
{
@@ -117,6 +121,7 @@ public class DataConnectionActorBatchTests : TestKit
ExpectMsg<SubscribeTagsResponse>(m => m.Success, TimeSpan.FromSeconds(5));
}
/// <summary>Verifies that a batch that throws at connection level fails the request and drives a reconnect.</summary>
[Fact]
public void ThrownBatch_AtConnectionLevel_FailsTheRequestAndDrivesReconnect()
{
@@ -135,6 +140,7 @@ public class DataConnectionActorBatchTests : TestKit
ExpectMsg<ConnectionQualityChanged>(q => q.Quality == QualityCode.Bad, TimeSpan.FromSeconds(5));
}
/// <summary>Verifies that tag-resolution probes are batched rather than issued one per tag.</summary>
[Fact]
public void ResolutionProbes_AreBatched_NotOnePerTag()
{
@@ -162,6 +168,7 @@ public class DataConnectionActorBatchTests : TestKit
Assert.Equal(0, Volatile.Read(ref adapter.SingleSubscribeCalls));
}
/// <summary>Verifies that resolution probes back off so a dead device is not probed at the full rate.</summary>
[Fact]
public void ResolutionProbes_BackOff_SoADeadDeviceIsNotProbedAtFullRate()
{
@@ -196,7 +203,7 @@ public class DataConnectionActorBatchTests : TestKit
}
/// <summary>
/// Row-40 house pattern adapted for a RATE assertion: proves a monotonically-increasing
/// House pattern adapted for a RATE assertion: proves a monotonically-increasing
/// count stays within <c>[minInclusive, maxInclusive]</c> across the whole
/// <paramref name="window"/>, rather than a single blind sleep-then-sample at the end.
/// The upper bound is enforced continuously (fails fast the instant it is exceeded,
@@ -225,7 +232,7 @@ public class DataConnectionActorBatchTests : TestKit
}
/// <summary>
/// Row-40 house pattern for an ABSENCE assertion (mirrors the Medium-wave
/// House pattern for an ABSENCE assertion (mirrors the Medium-wave
/// <c>SiteAlarmAggregatorActorTests.AssertHoldsFor</c> treatment): proves
/// <paramref name="holds"/> keeps being true for the whole <paramref name="window"/> by
/// polling, rather than a single blind <c>Thread.Sleep(window)</c> followed by one
@@ -246,6 +253,7 @@ public class DataConnectionActorBatchTests : TestKit
}
}
/// <summary>Verifies that quality counters are pushed only on a transition and are coalesced.</summary>
[Fact]
public void QualityCounters_ArePushedOnTransitionOnly_AndCoalesced()
{
@@ -279,6 +287,7 @@ public class DataConnectionActorBatchTests : TestKit
"the coalesced push must not repeat");
}
/// <summary>Verifies that quality counters are flushed immediately on disconnect.</summary>
[Fact]
public void QualityCounters_FlushImmediatelyOnDisconnect()
{
@@ -301,6 +310,7 @@ public class DataConnectionActorBatchTests : TestKit
TimeSpan.FromSeconds(3));
}
/// <summary>Verifies that unsubscribing releases every handle in a single round trip.</summary>
[Fact]
public void Unsubscribe_ReleasesEveryHandleInOneRoundTrip()
{
@@ -317,6 +327,7 @@ public class DataConnectionActorBatchTests : TestKit
Assert.Equal(6, released!.Count);
}
/// <summary>Verifies that a reconnect re-subscribes in paced chunks and restores the resolved count.</summary>
[Fact]
public void Reconnect_ReSubscribesInPacedChunks_AndRestoresTheResolvedCount()
{
@@ -353,6 +364,7 @@ public class DataConnectionActorBatchTests : TestKit
TimeSpan.FromSeconds(10));
}
/// <summary>Verifies that a resolution probe completing after an unsubscribe is discarded and its handle released.</summary>
[Fact]
public void ResolutionProbe_CompletingAfterUnsubscribe_IsDiscarded_AndItsHandleIsReleased()
{
@@ -432,6 +444,7 @@ public class DataConnectionActorBatchTests : TestKit
Assert.Equal(0, afterFinalUnsubscribe.ResolvedTags);
}
/// <summary>Verifies that a reconnect re-subscribe completing after an unsubscribe is discarded and its handle released.</summary>
[Fact]
public void ReconnectResubscribe_CompletingAfterUnsubscribe_IsDiscarded_AndItsHandleIsReleased()
{
@@ -483,7 +496,7 @@ public class DataConnectionActorBatchTests : TestKit
// ── Tag-resolution health counts: derived from the per-tag counted set ──
//
// Arch-review remediation residual #1. TotalSubscribedTags / ResolvedTags used to be
// TotalSubscribedTags / ResolvedTags used to be
// two int fields incremented and decremented at five independent sites. The reconnect
// path cleared the very maps those decrements keyed off (_subscriptionIds and
// _unresolvedTags), so an unsubscribe landing inside a reconnect window matched NEITHER
@@ -491,6 +504,7 @@ public class DataConnectionActorBatchTests : TestKit
// under churn. They are now derived at report time from _instancesByTag (the per-tag
// counted set) and _subscriptionIds, which makes that class of drift unrepresentable.
/// <summary>Verifies that an unsubscribe inside a reconnect window does not leak a subscribed tag into the counts.</summary>
[Fact]
public void TagResolutionCounts_UnsubscribeInsideAReconnectWindow_DoesNotLeakASubscribedTag()
{
@@ -546,6 +560,7 @@ public class DataConnectionActorBatchTests : TestKit
}
}
/// <summary>Verifies that a shared tag unsubscribed inside a reconnect window still counts for the other instance.</summary>
[Fact]
public void TagResolutionCounts_SharedTagUnsubscribedInsideAReconnectWindow_StillCountsForTheOtherInstance()
{
@@ -599,6 +614,7 @@ public class DataConnectionActorBatchTests : TestKit
AwaitCondition(() => adapter.UnsubscribeBatches.Any(b => b.Count == 1), TimeSpan.FromSeconds(10));
}
/// <summary>Verifies that after a connection-level failure and recovery the resolved count stays within the total.</summary>
[Fact]
public void TagResolutionCounts_ConnectionLevelFailureThenRecovery_KeepResolvedWithinTheTotal()
{
@@ -645,6 +661,7 @@ public class DataConnectionActorBatchTests : TestKit
Assert.Equal(0, afterUnsubscribe.ResolvedTags);
}
/// <summary>Verifies that tag-resolution counts stay correct across plain subscribe and unsubscribe cycles.</summary>
[Fact]
public void TagResolutionCounts_SurvivePlainSubscribeUnsubscribeCycles()
{
@@ -680,6 +697,7 @@ public class DataConnectionActorBatchTests : TestKit
}
}
/// <summary>Verifies that tag-resolution counts are carried through a completed reconnect re-subscribe.</summary>
[Fact]
public void TagResolutionCounts_AreCarriedThroughACompletedReconnectReSubscribe()
{
@@ -10,7 +10,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors;
/// <summary>
/// Arch-review 2026-08-19 <b>P2-6</b> — the <c>AdapterDisconnected</c> gap in the
/// The <c>AdapterDisconnected</c> gap in the
/// Connecting/Reconnecting states, and the generation fence that keeps a swapped-out adapter's
/// disconnect from disturbing its replacement.
///
@@ -28,7 +28,7 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors;
///
/// <para>
/// The fix must not weaken the Medium-wave generation fencing: a disconnect raised by an adapter
/// that has since been swapped out (failover, or the row-97 in-place reconfigure) must still be
/// that has since been swapped out (failover, or the in-place reconfigure) must still be
/// dropped, or the endpoint the operator has just corrected AWAY from could knock the healthy
/// replacement back into a reconnect cycle.
/// </para>
@@ -113,7 +113,7 @@ public class DataConnectionDisconnectRaceTests : TestKit
{
const string name = "disconnect-before-connect-result";
var firstConnect = new TaskCompletionSource();
// Attempt #2 onward never completes, so "the actor did not go Connected on the dead
// Attempt 2 onward never completes, so "the actor did not go Connected on the dead
// session" is a stable assertion rather than a race against the next retry tick.
var laterConnects = new TaskCompletionSource();
var connects = 0;
@@ -158,7 +158,7 @@ public class DataConnectionDisconnectRaceTests : TestKit
const string name = "disconnect-before-reconnect-result";
var firstConnect = new TaskCompletionSource();
var secondConnect = new TaskCompletionSource();
// Attempt #3 onward never completes, so the actor parks in Reconnecting and the
// Attempt 3 onward never completes, so the actor parks in Reconnecting and the
// "did not go Connected on the dead session" assertion is stable rather than a race
// against the next retry tick.
var laterConnects = new TaskCompletionSource();
@@ -181,7 +181,7 @@ public class DataConnectionDisconnectRaceTests : TestKit
AwaitCondition(() => HealthTransitions(name, ConnectionHealth.Connected) >= 1,
TimeSpan.FromSeconds(5), TimeSpan.FromMilliseconds(10));
// Drop while Connected → Reconnecting, and the retry timer launches attempt #2.
// Drop while Connected → Reconnecting, and the retry timer launches attempt 2.
adapter.Disconnected += Raise.Event<Action>();
AwaitCondition(() => Volatile.Read(ref connects) >= 2,
TimeSpan.FromSeconds(5), TimeSpan.FromMilliseconds(10));
@@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors;
/// <summary>
/// Task 10 (opcua-tag-browser): the site-side
/// OPC UA tag browser: the site-side
/// <see cref="DataConnectionManagerActor"/> + child
/// <see cref="DataConnectionActor"/> together resolve
/// <see cref="BrowseNodeCommand"/> against the live adapter and surface
@@ -29,6 +29,7 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit
private readonly ISiteHealthCollector _healthCollector;
private readonly DataConnectionOptions _options;
/// <summary>Initializes a new instance of <c>DataConnectionManagerBrowseHandlerTests</c>.</summary>
public DataConnectionManagerBrowseHandlerTests()
: base(@"akka.loglevel = WARNING")
{
@@ -41,6 +42,7 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit
};
}
/// <summary>Verifies that unknown connection name returns connection not found.</summary>
[Fact]
public void Unknown_connection_name_returns_ConnectionNotFound()
{
@@ -58,6 +60,7 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit
Assert.Empty(reply.Children);
}
/// <summary>Verifies that non browsable adapter returns not browsable.</summary>
[Fact]
public void Non_browsable_adapter_returns_NotBrowsable()
{
@@ -88,6 +91,7 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit
Assert.Empty(reply.Children);
}
/// <summary>Verifies that success path returns mapped children.</summary>
[Fact]
public void Success_path_returns_mapped_children()
{
@@ -130,6 +134,7 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit
Assert.False(reply.Truncated);
}
/// <summary>Verifies that connection not connected exception maps to connection not connected.</summary>
[Fact]
public void ConnectionNotConnectedException_maps_to_ConnectionNotConnected()
{
@@ -163,6 +168,7 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit
Assert.Empty(reply.Children);
}
/// <summary>Verifies that not supported exception maps to not browsable carrying message.</summary>
[Fact]
public void NotSupportedException_maps_to_NotBrowsable_carrying_message()
{
@@ -207,6 +213,7 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit
Assert.Empty(reply.Children);
}
/// <summary>Verifies that oversized child list is capped to frame budget and marked truncated.</summary>
[Fact]
public void Oversized_child_list_is_capped_to_frame_budget_and_marked_truncated()
{
@@ -254,10 +261,11 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit
Assert.True(keptBytes <= byteBudget, $"kept estimate {keptBytes} exceeds budget {byteBudget}");
}
/// <summary>Verifies that continuation token round trips through the browse handler.</summary>
[Fact]
public void ContinuationToken_round_trips_through_the_browse_handler()
{
// Task B3 (T15): the BrowseNext continuation token must thread through the
// The BrowseNext continuation token must thread through the
// actor plumbing in both directions — the inbound command's token must reach
// the adapter, and the adapter's returned token must be surfaced on the reply
// so the UI can request the next page. We capture the token the adapter is
@@ -30,6 +30,7 @@ public class DataConnectionManagerReadTagValuesHandlerTests : TestKit
private readonly ISiteHealthCollector _healthCollector;
private readonly DataConnectionOptions _options;
/// <summary>Initializes the actor system and fakes shared by the read-tag-values handler tests.</summary>
public DataConnectionManagerReadTagValuesHandlerTests()
: base(@"akka.loglevel = WARNING")
{
@@ -42,6 +43,7 @@ public class DataConnectionManagerReadTagValuesHandlerTests : TestKit
};
}
/// <summary>Verifies that an unknown connection name returns a ConnectionNotFound outcome.</summary>
[Fact]
public void Unknown_connection_name_returns_ConnectionNotFound()
{
@@ -59,6 +61,7 @@ public class DataConnectionManagerReadTagValuesHandlerTests : TestKit
Assert.Empty(reply.Outcomes);
}
/// <summary>Verifies that an adapter that is not connected returns a ConnectionNotConnected outcome.</summary>
[Fact]
public void Adapter_not_connected_returns_ConnectionNotConnected()
{
@@ -94,6 +97,7 @@ public class DataConnectionManagerReadTagValuesHandlerTests : TestKit
adapter.DidNotReceive().ReadBatchAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that a successful read maps the adapter results to tag read outcomes.</summary>
[Fact]
public void Success_path_maps_results_to_TagReadOutcomes()
{
@@ -143,6 +147,7 @@ public class DataConnectionManagerReadTagValuesHandlerTests : TestKit
Assert.Equal("BadNodeIdUnknown", bOutcome.ErrorMessage);
}
/// <summary>Verifies that a cancelled adapter operation is reported as a Timeout outcome.</summary>
[Fact]
public void Adapter_OperationCancelled_returns_Timeout()
{
@@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors;
/// <summary>
/// Task B5 (T15): the site-side <see cref="DataConnectionManagerActor"/> + child
/// The site-side <see cref="DataConnectionManagerActor"/> + child
/// <see cref="DataConnectionActor"/> together resolve a
/// <see cref="SearchAddressSpaceCommand"/> against the live adapter and surface
/// every search outcome as a typed <see cref="BrowseFailure"/> (the search path
@@ -29,6 +29,7 @@ public class DataConnectionManagerSearchHandlerTests : TestKit
private readonly ISiteHealthCollector _healthCollector;
private readonly DataConnectionOptions _options;
/// <summary>Initializes a new instance of the <see cref="DataConnectionManagerSearchHandlerTests"/> class.</summary>
public DataConnectionManagerSearchHandlerTests()
: base(@"akka.loglevel = WARNING")
{
@@ -41,6 +42,7 @@ public class DataConnectionManagerSearchHandlerTests : TestKit
};
}
/// <summary>Verifies that a search against an unknown connection name returns a ConnectionNotFound failure.</summary>
[Fact]
public void Unknown_connection_name_returns_ConnectionNotFound()
{
@@ -59,6 +61,7 @@ public class DataConnectionManagerSearchHandlerTests : TestKit
Assert.False(reply.CapReached);
}
/// <summary>Verifies that a search against an adapter that cannot search returns a NotBrowsable failure.</summary>
[Fact]
public void Non_searchable_adapter_returns_NotBrowsable()
{
@@ -87,6 +90,7 @@ public class DataConnectionManagerSearchHandlerTests : TestKit
Assert.Empty(reply.Matches);
}
/// <summary>Verifies that a successful search returns the mapped matches.</summary>
[Fact]
public void Success_path_returns_mapped_matches()
{
@@ -134,6 +138,7 @@ public class DataConnectionManagerSearchHandlerTests : TestKit
Assert.True(reply.CapReached);
}
/// <summary>Verifies that the query, depth and result limit are passed through to the adapter.</summary>
[Fact]
public void Query_depth_and_results_thread_through_to_the_adapter()
{
@@ -178,6 +183,7 @@ public class DataConnectionManagerSearchHandlerTests : TestKit
Assert.Empty(reply.Matches);
}
/// <summary>Verifies that a ConnectionNotConnectedException maps to a ConnectionNotConnected failure.</summary>
[Fact]
public void ConnectionNotConnectedException_maps_to_ConnectionNotConnected()
{
@@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors;
/// <summary>
/// Arch-review 2026-08-19 <b>P2-4</b> (classification half) — how a thrown
/// Classification half — how a thrown
/// <see cref="ServiceResultException"/> out of a batch subscribe is classified.
///
/// <para>
@@ -10,7 +10,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors;
/// <summary>
/// Arch-review 2026-08-19 <b>P2-5</b> — the tag-resolution PROBE path did not seed the tags it
/// The tag-resolution PROBE path did not seed the tags it
/// resolved.
///
/// <para>
@@ -43,6 +43,7 @@ public class DataConnectionReconfigureTests : TestKit
private readonly ISiteHealthCollector _healthCollector;
private readonly IDataConnectionFactory _factory;
/// <summary>Initializes a new instance of the <c>DataConnectionReconfigureTests</c> class.</summary>
public DataConnectionReconfigureTests()
: base(@"akka.loglevel = DEBUG")
{
@@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors;
/// <summary>
/// Arch-review 2026-08-19 <b>P2-1</b> — an in-flight subscribe that completes AFTER the adapter
/// An in-flight subscribe that completes AFTER the adapter
/// has been swapped out (failover, or the in-place reconfigure of register row 97).
///
/// <para>
@@ -10,13 +10,13 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors;
/// <summary>
/// Arch-review 2026-08-19 <b>P2-3</b> — the connect leg of the stale-completion family that
/// The connect leg of the stale-completion family that
/// <c>DataConnectionStaleCompletionTests</c> pins for the subscribe leg.
///
/// <para>
/// <b>The gap these pin.</b> <c>ConnectResult</c> was piped untagged, so a connect launched
/// against adapter generation N and answered after the adapter had been swapped (failover, or the
/// row-97 in-place reconfigure) was applied to its REPLACEMENT. A stale success drove
/// in-place reconfigure) was applied to its REPLACEMENT. A stale success drove
/// <c>ReSubscribeAll</c> and <c>BecomeConnected</c> against an adapter that had never connected;
/// the replacement's own result then landed in the Connected state, where nothing handles it. With
/// a bad new endpoint the batch subscribes failed at connection level with a log line only, no
@@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors;
/// <summary>
/// In-memory batch-capable <see cref="IDataConnection"/> for the WP2.1b seam tests. It
/// In-memory batch-capable <see cref="IDataConnection"/> for the batch seam tests. It
/// records every batch call (so "one round trip per chunk, not per tag" is assertable),
/// can fail individual tags or a whole batch, and can hang its bulk read so the seed
/// deadline is observable.
@@ -55,15 +55,17 @@ public sealed class FakeBatchDataConnection
/// <summary>Callback the last alarm subscribe registered.</summary>
public AlarmTransitionCallback? AlarmCallback;
// Fake behaviour: Gets the simulated connection health, which follows connect and disconnect calls.
/// <inheritdoc />
public ConnectionHealth Status { get; private set; } = ConnectionHealth.Disconnected;
/// <inheritdoc />
/// <summary>Occurs when the fake raises a simulated transport fault.</summary>
public event Action? Disconnected;
/// <summary>Raises <see cref="Disconnected"/> as a real adapter would on a transport fault.</summary>
public void RaiseDisconnected() => Disconnected?.Invoke();
// Fake behaviour: Marks the fake as connected.
/// <inheritdoc />
public Task ConnectAsync(IDictionary<string, string> connectionDetails, CancellationToken cancellationToken = default)
{
@@ -71,6 +73,7 @@ public sealed class FakeBatchDataConnection
return Task.CompletedTask;
}
// Fake behaviour: Marks the fake as disconnected.
/// <inheritdoc />
public Task DisconnectAsync(CancellationToken cancellationToken = default)
{
@@ -78,6 +81,7 @@ public sealed class FakeBatchDataConnection
return Task.CompletedTask;
}
// Fake behaviour: Records the batch, captures the callback and returns a per-tag result, failing the tags configured to fail.
/// <inheritdoc />
public async Task<IReadOnlyList<TagSubscribeResult>> SubscribeBatchAsync(
IReadOnlyList<string> tagPaths, SubscriptionCallback callback, CancellationToken cancellationToken = default)
@@ -100,6 +104,7 @@ public sealed class FakeBatchDataConnection
return rows;
}
// Fake behaviour: Records the subscription ids passed to the batch unsubscribe.
/// <inheritdoc />
public Task UnsubscribeBatchAsync(IReadOnlyList<string> subscriptionIds, CancellationToken cancellationToken = default)
{
@@ -107,6 +112,7 @@ public sealed class FakeBatchDataConnection
return Task.CompletedTask;
}
// Fake behaviour: Counts the single-tag subscribe call and captures the callback.
/// <inheritdoc />
public Task<string> SubscribeAsync(string tagPath, SubscriptionCallback callback, CancellationToken cancellationToken = default)
{
@@ -115,10 +121,12 @@ public sealed class FakeBatchDataConnection
return Task.FromResult($"sub-{Interlocked.Increment(ref _nextId)}");
}
// Fake behaviour: Completes without doing anything.
/// <inheritdoc />
public Task UnsubscribeAsync(string subscriptionId, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
// Fake behaviour: Counts the single-tag read and returns the seed value, or hangs when reads are configured to hang.
/// <inheritdoc />
public async Task<ReadResult> ReadAsync(string tagPath, CancellationToken cancellationToken = default)
{
@@ -128,6 +136,7 @@ public sealed class FakeBatchDataConnection
return new ReadResult(true, new TagValue(SeedValue, QualityCode.Good, DateTimeOffset.UtcNow), null);
}
// Fake behaviour: Records the batch read and returns the seed value for every tag, or hangs when reads are configured to hang.
/// <inheritdoc />
public async Task<IReadOnlyDictionary<string, ReadResult>> ReadBatchAsync(
IEnumerable<string> tagPaths, CancellationToken cancellationToken = default)
@@ -142,22 +151,26 @@ public sealed class FakeBatchDataConnection
t => new ReadResult(true, new TagValue(SeedValue, QualityCode.Good, DateTimeOffset.UtcNow), null));
}
// Fake behaviour: Reports a successful write without storing anything.
/// <inheritdoc />
public Task<WriteResult> WriteAsync(string tagPath, object? value, CancellationToken cancellationToken = default)
=> Task.FromResult(new WriteResult(true, null));
// Fake behaviour: Reports a successful write for every entry without storing anything.
/// <inheritdoc />
public Task<IReadOnlyDictionary<string, WriteResult>> WriteBatchAsync(
IDictionary<string, object?> values, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyDictionary<string, WriteResult>>(
values.ToDictionary(kv => kv.Key, _ => new WriteResult(true, null)));
// Fake behaviour: Reports that the handshake matched without writing anything.
/// <inheritdoc />
public Task<bool> WriteBatchAndWaitAsync(
IDictionary<string, object?> values, string flagPath, object? flagValue, string responsePath,
object? responseValue, TimeSpan timeout, CancellationToken cancellationToken = default)
=> Task.FromResult(true);
// Fake behaviour: Captures the alarm callback and returns a generated subscription id.
/// <inheritdoc />
public Task<string> SubscribeAlarmsAsync(
string sourceReference, string? conditionFilter, AlarmTransitionCallback callback,
@@ -167,10 +180,12 @@ public sealed class FakeBatchDataConnection
return Task.FromResult($"alarm-{Interlocked.Increment(ref _nextId)}");
}
// Fake behaviour: Completes without doing anything.
/// <inheritdoc />
public Task UnsubscribeAlarmsAsync(string subscriptionId, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
/// <inheritdoc />
/// <summary>Completes immediately because the fake holds no resources.</summary>
/// <returns>A completed value task.</returns>
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
@@ -33,6 +33,7 @@ public class HighReviewRegressionTests : TestKit
return ExpectMsg<DataConnectionHealthReport>();
}
/// <summary>Verifies that a resolution tick does not duplicate an initial subscribe that is already in flight.</summary>
[Fact]
public void ResolutionTick_DoesNotDuplicateAnInitialSubscribeAlreadyInFlight()
{
@@ -56,6 +57,7 @@ public class HighReviewRegressionTests : TestKit
finally { gate.SetResult(); }
}
/// <summary>Verifies that an in-flight handle still serves a different instance after its original owner dies.</summary>
[Fact]
public void OriginalOwnerDies_InFlightHandleStillServesDifferentInstance()
{
@@ -79,6 +81,7 @@ public class HighReviewRegressionTests : TestKit
Assert.DoesNotContain(adapter.ReceivedCalls(), c => c.GetMethodInfo().Name == "UnsubscribeAsync");
}
/// <summary>Verifies that a subscriber terminating during subscribe releases the late handle without resurrecting the subscription.</summary>
[Fact]
public void SubscriberTermination_DuringSubscribe_ReleasesLateHandleWithoutResurrection()
{
@@ -97,6 +100,7 @@ public class HighReviewRegressionTests : TestKit
Assert.Equal(0, Health(actor).ResolvedTags);
}
/// <summary>Verifies that a replacement during subscribe adopts only the tags that are still requested.</summary>
[Fact]
public void Replacement_DuringSubscribe_AdoptsOnlyStillRequestedTags()
{
@@ -117,6 +121,7 @@ public class HighReviewRegressionTests : TestKit
Assert.Equal(1, Health(actor).TotalSubscribedTags);
}
/// <summary>Verifies that a subscriber terminating during resolution releases the late handle.</summary>
[Fact]
public void SubscriberTermination_DuringResolution_ReleasesLateHandle()
{
@@ -139,6 +144,7 @@ public class HighReviewRegressionTests : TestKit
Assert.Equal(0, Health(actor).ResolvedTags);
}
/// <summary>Verifies that retried subscribes while disconnected keep only the latest request.</summary>
[Fact]
public void DisconnectedSubscribeRetries_KeepOnlyLatestRequest()
{
@@ -154,6 +160,8 @@ public class HighReviewRegressionTests : TestKit
Assert.Single(adapter.ReceivedCalls(), c => c.GetMethodInfo().Name == "SubscribeAsync");
}
/// <summary>Verifies that writes while disconnected are rejected and never replayed after reconnect.</summary>
/// <param name="reconnect">Whether the connection is re-established after the rejected write.</param>
[Theory]
[InlineData(false)]
[InlineData(true)]
@@ -180,6 +188,7 @@ public class HighReviewRegressionTests : TestKit
Assert.DoesNotContain(adapter.ReceivedCalls(), c => c.GetMethodInfo().Name is "WriteAsync" or "WriteBatchAsync");
}
/// <summary>Verifies that a subscriber terminating releases the handle of its last tag.</summary>
[Fact]
public void SubscriberTermination_ReleasesLastTagHandle()
{
@@ -192,6 +201,7 @@ public class HighReviewRegressionTests : TestKit
AwaitCondition(() => adapter.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "UnsubscribeAsync" && Equals(c.GetArguments()[0], "handle")), TimeSpan.FromSeconds(1));
}
/// <summary>Verifies that an unsubscribe from the old owner does not remove the replacement's tags.</summary>
[Fact]
public void OldOwnerUnsubscribe_DoesNotRemoveReplacementTags()
{
@@ -213,6 +223,7 @@ public class HighReviewRegressionTests : TestKit
Assert.DoesNotContain(adapter.ReceivedCalls(), c => c.GetMethodInfo().Name == "UnsubscribeAsync");
}
/// <summary>Verifies that a duplicate completion with a live handle does not unsubscribe it.</summary>
[Fact]
public void DuplicateCompletion_WithLiveHandle_DoesNotUnsubscribeIt()
{
@@ -226,6 +237,7 @@ public class HighReviewRegressionTests : TestKit
Assert.DoesNotContain(adapter.ReceivedCalls(), c => c.GetMethodInfo().Name == "UnsubscribeAsync");
}
/// <summary>Verifies that a source-scoped snapshot end is not broadcast to other sources.</summary>
[Fact]
public void SourceScopedSnapshotEnd_IsNotBroadcastToOtherSources()
{
@@ -244,6 +256,8 @@ public class HighReviewRegressionTests : TestKit
b.ExpectMsg<NativeAlarmTransitionUpdate>();
a.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
/// <summary>Verifies that a second subscriber receives the standing alarm snapshot.</summary>
/// <param name="protocol">The data connection protocol under test.</param>
[Theory]
[InlineData("OpcUa")]
[InlineData("MxGateway")]
@@ -281,6 +295,8 @@ public class HighReviewRegressionTests : TestKit
Assert.Contains(received.OfType<NativeAlarmTransitionUpdate>(), m => m.Transition.Kind == AlarmTransitionKind.SnapshotComplete);
}
/// <summary>Verifies that a subscriber joining mid-snapshot receives the prefix already seen.</summary>
/// <param name="scoped">Whether the snapshot is scoped to a single source.</param>
[Theory]
[InlineData(false)]
[InlineData(true)]
@@ -313,6 +329,8 @@ public class HighReviewRegressionTests : TestKit
Assert.Equal(alarm.SnapshotId, end.Transition.SnapshotId);
}
/// <summary>Verifies that replay rejects old-epoch rows and preserves a newer live clear.</summary>
/// <param name="staleEpoch">Whether the replayed rows belong to a stale epoch.</param>
[Theory]
[InlineData(false)]
[InlineData(true)]
@@ -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>
@@ -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()
{
@@ -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)
@@ -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()
{
@@ -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()
{
@@ -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()
{
@@ -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()
{
@@ -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=&lt;uri&gt;</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,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()
{
@@ -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()
{
@@ -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()
{
@@ -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()
{
@@ -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()
{
@@ -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()
{
@@ -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()
{
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.DataConnectionLayer;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
/// <summary>
/// M2.4 (#8): the alarm conditionFilter is a comma-separated, case-insensitive
/// The alarm conditionFilter is a comma-separated, case-insensitive
/// list of condition type names. Blank = allow all. These tests pin the
/// parse-once / IsAllowed predicate that the DataConnectionActor uses as the
/// authoritative client-side gate.
@@ -18,6 +18,8 @@ public class AlarmConditionFilterTests
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 500),
"cat", "desc", "msg", "", "", null, DateTimeOffset.UtcNow, "1", "0");
/// <summary>Verifies that a null or blank filter is empty and allows every condition type.</summary>
/// <param name="filter">The condition filter text under test.</param>
[Theory]
[InlineData(null)]
[InlineData("")]
@@ -32,6 +34,7 @@ public class AlarmConditionFilterTests
Assert.True(f.IsAllowed(Tx("anything-at-all")));
}
/// <summary>Verifies that parsing splits a comma-separated list into individual names.</summary>
[Fact]
public void Parse_SplitsCommaSeparatedList()
{
@@ -43,6 +46,7 @@ public class AlarmConditionFilterTests
Assert.False(f.IsAllowed(Tx("AnalogLimit.HiHi")));
}
/// <summary>Verifies that the allowed check ignores letter casing.</summary>
[Fact]
public void IsAllowed_IsCaseInsensitive()
{
@@ -52,6 +56,7 @@ public class AlarmConditionFilterTests
Assert.False(f.IsAllowed(Tx("DiscreteAlarm")));
}
/// <summary>Verifies that parsing trims whitespace around each name.</summary>
[Fact]
public void Parse_TrimsWhitespaceAroundEachName()
{
@@ -60,6 +65,7 @@ public class AlarmConditionFilterTests
Assert.True(f.IsAllowed(Tx("DiscreteAlarm")));
}
/// <summary>Verifies that parsing drops empty entries and keeps the non-empty ones.</summary>
[Fact]
public void Parse_DropsEmptyEntries_KeepsNonEmpty()
{
@@ -70,6 +76,7 @@ public class AlarmConditionFilterTests
Assert.False(f.IsAllowed(Tx("")));
}
/// <summary>Verifies that the snapshot-complete framing sentinel is never filtered out.</summary>
[Fact]
public void IsAllowed_NeverDropsSnapshotCompleteFramingSentinel()
{
@@ -80,6 +87,7 @@ public class AlarmConditionFilterTests
Assert.True(f.IsAllowed(Tx("", AlarmTransitionKind.SnapshotComplete)));
}
/// <summary>Verifies that replayed snapshot conditions are filtered by condition type.</summary>
[Fact]
public void IsAllowed_FiltersReplayedSnapshotConditionsByType()
{
@@ -89,6 +97,7 @@ public class AlarmConditionFilterTests
Assert.False(f.IsAllowed(Tx("DiscreteAlarm", AlarmTransitionKind.Snapshot)));
}
/// <summary>Verifies that the names property exposes the normalized set for server-side optimization.</summary>
[Fact]
public void Names_ExposesNormalizedSet_ForServerSideOptimization()
{
@@ -97,10 +106,11 @@ public class AlarmConditionFilterTests
Assert.Empty(AlarmConditionFilter.Parse(null).Names);
}
/// <summary>Verifies that an OPC UA event whose type name resolves to a friendly name matches a filter on that friendly name.</summary>
[Fact]
public void IsAllowed_OpcUaResolvedFriendlyName_MatchesFriendlyNameFilter()
{
// M2.4 (#8) regression: OPC UA delivers events whose AlarmTypeName, after
// Regression: OPC UA delivers events whose AlarmTypeName, after
// RealOpcUaClient.ResolveAlarmTypeName, is a standard friendly type name
// (e.g. "ExclusiveLevelAlarmType"). A friendly-name filter on that source
// built a correct server WhereClause; the client gate must agree and deliver,
@@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
/// <summary>
/// WP2.1b — the alarm subscriber first-segment bucket index must make EXACTLY the routing
/// The alarm subscriber first-segment bucket index must make EXACTLY the routing
/// decisions the previous linear scan made. The expectation in each case is computed with
/// the original rule (plain <c>StartsWith</c> against every subscribed source), so the
/// test pins equivalence rather than restating the new implementation.
@@ -53,6 +53,7 @@ public class DataConnectionActorAlarmIndexTests : TestKit
transitionSourceObjectRef.StartsWith(sourceRef, StringComparison.Ordinal)
|| transitionSourceRef.StartsWith(sourceRef, StringComparison.Ordinal);
/// <summary>Verifies that the bucket index routes exactly as the previous linear scan did.</summary>
[Fact]
public void BucketIndex_RoutesExactlyLikeTheLinearScan()
{
@@ -98,6 +99,7 @@ public class DataConnectionActorAlarmIndexTests : TestKit
probes[source].ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
/// <summary>Verifies that a snapshot-complete message is still broadcast to every subscriber, bypassing the index.</summary>
[Fact]
public void SnapshotComplete_StillBroadcastsToEverySubscriber_BypassingTheIndex()
{
@@ -137,6 +139,7 @@ public class DataConnectionActorAlarmIndexTests : TestKit
}
}
/// <summary>Verifies that an unsubscribed source is dropped from the index.</summary>
[Fact]
public void UnsubscribedSource_IsDroppedFromTheIndex()
{
@@ -10,7 +10,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
/// <summary>Task-10: native alarm subscribe + source-ref routing + unavailable signal.</summary>
/// <summary>Native alarm subscribe + source-ref routing + unavailable signal.</summary>
public class DataConnectionActorAlarmTests : TestKit
{
private readonly ISiteHealthCollector _health = Substitute.For<ISiteHealthCollector>();
@@ -44,6 +44,7 @@ public class DataConnectionActorAlarmTests : TestKit
return (adapter, () => cb);
}
/// <summary>Verifies that an alarm subscription routes a transition to the instance subscriber.</summary>
[Fact]
public void SubscribeAlarms_RoutesTransitionToInstanceSubscriber()
{
@@ -67,10 +68,11 @@ public class DataConnectionActorAlarmTests : TestKit
ExpectMsg<NativeAlarmTransitionUpdate>(u => u.Transition.SourceObjectReference == "Tank01");
}
/// <summary>Verifies that an alarm bound by node id routes its transition to the subscriber.</summary>
[Fact]
public void SubscribeAlarms_NodeIdBinding_RoutesTransition()
{
// Gitea #17 end-to-end: an instance binds a native alarm source by NodeId (the form
// End-to-end: an instance binds a native alarm source by NodeId (the form
// the picker / CSV / config store). The adapter builds the transition's identity via
// OpcUaAlarmMapper.BuildIdentity(binding, SourceName, ConditionName). With the old
// "SourceObjectReference = SourceName" logic the event's name ("pymodbus/plc/HR200")
@@ -105,6 +107,7 @@ public class DataConnectionActorAlarmTests : TestKit
Assert.Equal("pymodbus/plc/HR200.HR200", update.Transition.SourceReference);
}
/// <summary>Verifies that subscribing on an adapter that cannot raise alarms replies with a failure.</summary>
[Fact]
public void SubscribeAlarms_OnNonAlarmCapableAdapter_RepliesFailure()
{
@@ -119,6 +122,7 @@ public class DataConnectionActorAlarmTests : TestKit
ExpectMsg<SubscribeAlarmsResponse>(m => !m.Success && m.ErrorMessage != null);
}
/// <summary>Verifies that a second subscriber with a different filter logs an overwrite warning.</summary>
[Fact]
public void SecondAlarmSubscriber_WithDifferentFilter_LogsOverwriteWarning()
{
@@ -142,8 +146,9 @@ public class DataConnectionActorAlarmTests : TestKit
});
}
// ── M2.4 (#8): conditionFilter is now applied client-side in the actor ──
// ── conditionFilter is now applied client-side in the actor ──
/// <summary>Verifies that a type filter delivers only matching alarm types.</summary>
[Fact]
public void SubscribeAlarms_WithTypeFilter_DeliversOnlyMatchingTypes()
{
@@ -166,6 +171,7 @@ public class DataConnectionActorAlarmTests : TestKit
ExpectMsg<NativeAlarmTransitionUpdate>(u => u.Transition.AlarmTypeName == "AnalogLimit.Hi");
}
/// <summary>Verifies that a null filter delivers all alarm types.</summary>
[Fact]
public void SubscribeAlarms_WithNullFilter_DeliversAllTypes()
{
@@ -184,6 +190,7 @@ public class DataConnectionActorAlarmTests : TestKit
ExpectMsg<NativeAlarmTransitionUpdate>(u => u.Transition.AlarmTypeName == "DiscreteAlarm");
}
/// <summary>Verifies that filter matching ignores letter case and surrounding whitespace.</summary>
[Fact]
public void SubscribeAlarms_FilterMatch_IgnoresCaseAndWhitespace()
{
@@ -205,6 +212,7 @@ public class DataConnectionActorAlarmTests : TestKit
ExpectNoMsg(TimeSpan.FromMilliseconds(250));
}
/// <summary>Verifies that a gateway-wide alarm feed is filtered client-side.</summary>
[Fact]
public void SubscribeAlarms_GatewayWideFeed_IsFilteredClientSide()
{
@@ -234,6 +242,7 @@ public class DataConnectionActorAlarmTests : TestKit
u.Transition.SourceObjectReference == "Reactor" && u.Transition.AlarmTypeName == "HighTemp");
}
/// <summary>Verifies that a filtered subscription still forwards the snapshot-complete sentinel.</summary>
[Fact]
public void SubscribeAlarms_WithFilter_StillForwardsSnapshotCompleteSentinel()
{
@@ -257,6 +266,7 @@ public class DataConnectionActorAlarmTests : TestKit
ExpectMsg<NativeAlarmTransitionUpdate>(u => u.Transition.Kind == AlarmTransitionKind.SnapshotComplete);
}
/// <summary>Verifies that a real snapshot-complete marker with an empty reference is broadcast to a specific source.</summary>
[Fact]
public void SubscribeAlarms_RealEmptyRefSnapshotComplete_IsBroadcastToSpecificSource()
{
@@ -293,13 +303,15 @@ public class DataConnectionActorAlarmTests : TestKit
ExpectMsg<NativeAlarmTransitionUpdate>(u => u.Transition.Kind == AlarmTransitionKind.SnapshotComplete);
}
// ── DataConnectionLayer-023: mid-flight alarm unsubscribe must release the adapter feed ──
// ── Mid-flight alarm unsubscribe must release the adapter feed ──
/// <summary>Verifies that unsubscribing during an in-flight alarm subscribe releases the adapter feed and leaves the actor state clean.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DCL023_UnsubscribeDuringInFlightAlarmSubscribe_ReleasesAdapterFeed_AndKeepsStateClean()
{
// Regression test for DataConnectionLayer-023. Previously the native-alarm
// subscribe path never inherited the DCL-021 obsolete-completion guard: if the
// Regression test: previously the native-alarm
// subscribe path never inherited the obsolete-completion guard: if the
// last subscriber unsubscribed while the adapter alarm subscribe was in flight,
// HandleUnsubscribeAlarms could not tear down the feed (the subscription id was
// not stored yet), and the late AlarmSubscribeCompleted unconditionally stored
@@ -368,20 +380,22 @@ public class DataConnectionActorAlarmTests : TestKit
"Tank01", Arg.Any<string?>(), Arg.Any<AlarmTransitionCallback>(), Arg.Any<CancellationToken>());
}
// ── DataConnectionLayer-029: a re-subscribe during an orphaned in-flight subscribe
// ── A re-subscribe during an orphaned in-flight subscribe
// must not leak a duplicate adapter feed ──
/// <summary>Verifies that a re-subscribe during an orphaned in-flight subscribe releases the duplicate feed without leaking.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DCL029_ResubscribeDuringOrphanedInFlightSubscribe_ReleasesDuplicateFeed_NoLeak()
{
// Regression test for DataConnectionLayer-029. The DCL-023 fix clears the in-flight
// Regression test: the mid-flight-unsubscribe fix clears the in-flight
// marker on unsubscribe, which reopens a double-subscribe window: unsubscribe (last
// subscriber, subId not stored yet) → a fresh subscribe for the SAME source sees
// neither a stored id nor an in-flight marker, so it issues a SECOND adapter feed →
// both completions fire. The DCL-023 orphan guard does NOT trigger on either
// both completions fire. The orphan guard does NOT trigger on either
// completion (the re-subscribe re-added the subscriber), so the alarm completion
// handler used to OVERWRITE _alarmSubscriptionIds with the second id — leaking the
// first feed (never unsubscribed, kept streaming). After DCL-029 the handler mirrors
// first feed (never unsubscribed, kept streaming). With the fix the handler mirrors
// the tag-path re-check: when a feed is already stored, the redundant completion
// releases its just-created feed instead of overwriting + leaking.
var sub1Started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -413,35 +427,35 @@ public class DataConnectionActorAlarmTests : TestKit
var actor = Sys.ActorOf(Props.Create(() => new DataConnectionActor(
"conn", adapter, _options, _health, _factory, "OpcUa")));
// (1) Subscribe A — adapter subscribe #1 parks, in-flight={Tank01}.
// (1) Subscribe A — the first adapter subscribe parks, in-flight={Tank01}.
actor.Tell(new SubscribeAlarmsRequest("c1", "instA", "conn", "Tank01", null, DateTimeOffset.UtcNow));
await sub1Started.Task.WaitAsync(TimeSpan.FromSeconds(5));
// (2) Last subscriber unsubscribes while subscribe #1 is in flight — clears the
// in-flight marker (DCL-023); subId#1 is not stored yet so no teardown happens.
// (2) Last subscriber unsubscribes while the first subscribe is in flight — clears the
// in-flight marker; the first subscription id is not stored yet so no teardown happens.
actor.Tell(new UnsubscribeAlarmsRequest("unsub-c1", "instA", "conn", "Tank01", DateTimeOffset.UtcNow));
await Task.Delay(150);
// (3) Fresh subscribe for the SAME source before #1 completes — neither a stored id
// (3) Fresh subscribe for the SAME source before the first completes — neither a stored id
// nor an in-flight marker exists, so the actor issues a SECOND adapter subscribe.
actor.Tell(new SubscribeAlarmsRequest("c2", "instB", "conn", "Tank01", null, DateTimeOffset.UtcNow));
await sub2Started.Task.WaitAsync(TimeSpan.FromSeconds(5));
// (4) Complete subscribe #1 → a subscriber exists again (B re-added), so the orphan
// guard does NOT fire and subId#1 is stored as the live feed.
// (4) Complete the first subscribe → a subscriber exists again (B re-added), so the orphan
// guard does NOT fire and its subscription id is stored as the live feed.
sub1Release.SetResult("alarm-sub-1");
await Task.Delay(150);
// (5) Complete subscribe #2 → a feed is already stored, so this redundant completion
// releases its just-created feed (#2) instead of overwriting + leaking subId#1.
// (5) Complete the second subscribe → a feed is already stored, so this redundant completion
// releases its just-created feed instead of overwriting + leaking the first subscription id.
sub2Release.SetResult("alarm-sub-2");
await Task.Delay(300);
// The duplicate feed (#2) is released exactly once; the first feed (#1) is retained.
// The duplicate (second) feed is released exactly once; the first feed is retained.
await alarmable.Received(1).UnsubscribeAlarmsAsync("alarm-sub-2", Arg.Any<CancellationToken>());
await alarmable.DidNotReceive().UnsubscribeAlarmsAsync("alarm-sub-1", Arg.Any<CancellationToken>());
// The retained feed (#1) is what a later unsubscribe tears down — proving subId#1,
// The retained (first) feed is what a later unsubscribe tears down — proving the first subscription id,
// not the duplicate, is the id _alarmSubscriptionIds actually tracks (no leak).
actor.Tell(new UnsubscribeAlarmsRequest("unsub-c2", "instB", "conn", "Tank01", DateTimeOffset.UtcNow));
await Task.Delay(200);
@@ -11,14 +11,14 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
/// <summary>
/// WP-6: Tests for DataConnectionActor Become/Stash state machine.
/// WP-9: Auto-reconnect and bad quality tests.
/// WP-10: Transparent re-subscribe tests.
/// WP-11: Write-back support tests.
/// WP-12: Tag path resolution with retry tests.
/// WP-13: Health reporting tests.
/// WP-14: Subscription lifecycle tests.
/// Task-4: Failover state machine tests.
/// Tests for DataConnectionActor Become/Stash state machine.
/// Auto-reconnect and bad quality tests.
/// Transparent re-subscribe tests.
/// Write-back support tests.
/// Tag path resolution with retry tests.
/// Health reporting tests.
/// Subscription lifecycle tests.
/// Failover state machine tests.
/// </summary>
public class DataConnectionActorTests : TestKit
{
@@ -27,6 +27,7 @@ public class DataConnectionActorTests : TestKit
private readonly ISiteHealthCollector _mockHealthCollector;
private readonly IDataConnectionFactory _mockFactory;
/// <summary>Initializes a new instance of <c>DataConnectionActorTests</c>.</summary>
public DataConnectionActorTests()
: base(@"akka.loglevel = DEBUG")
{
@@ -39,7 +40,7 @@ public class DataConnectionActorTests : TestKit
TagResolutionRetryInterval = TimeSpan.FromMilliseconds(200),
WriteTimeout = TimeSpan.FromSeconds(5),
// Default to a single seed-read attempt so existing tests behave exactly as
// before the DCL-027 retry was added; the retry-specific tests opt in.
// before the seed-read retry was added; the retry-specific tests opt in.
SeedReadMaxAttempts = 1,
SeedReadRetryDelay = TimeSpan.FromMilliseconds(10)
};
@@ -93,6 +94,7 @@ public class DataConnectionActorTests : TestKit
c.GetArguments() is [string name, ConnectionHealth.Connected, ..] &&
name == connectionName);
/// <summary>Verifies that starts in connecting state attempts connect.</summary>
[Fact]
public void WP6_StartsInConnectingState_AttemptsConnect()
{
@@ -107,6 +109,7 @@ public class DataConnectionActorTests : TestKit
TimeSpan.FromSeconds(2));
}
/// <summary>Verifies that connecting state stashes subscribe requests.</summary>
[Fact]
public void WP6_ConnectingState_StashesSubscribeRequests()
{
@@ -134,6 +137,8 @@ public class DataConnectionActorTests : TestKit
ExpectMsg<SubscribeTagsResponse>(TimeSpan.FromSeconds(2));
}
/// <summary>Verifies that connected state write returns result.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WP11_ConnectedState_Write_ReturnsResult()
{
@@ -158,6 +163,8 @@ public class DataConnectionActorTests : TestKit
Assert.True(response.Success);
}
/// <summary>Verifies that write failure returned synchronously.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WP11_Write_Failure_ReturnedSynchronously()
{
@@ -177,6 +184,8 @@ public class DataConnectionActorTests : TestKit
Assert.Equal("Device offline", response.ErrorMessage);
}
/// <summary>Verifies that health report returns connection status.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WP13_HealthReport_ReturnsConnectionStatus()
{
@@ -195,8 +204,10 @@ public class DataConnectionActorTests : TestKit
Assert.Equal(ConnectionHealth.Connected, report.Status);
}
// ── Task-4: Failover state machine tests ──
// ── Failover state machine tests ──
/// <summary>Verifies that failover after n retries switches to backup.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Task4_FailoverAfterNRetries_SwitchesToBackup()
{
@@ -242,6 +253,8 @@ public class DataConnectionActorTests : TestKit
TimeSpan.FromSeconds(5));
}
/// <summary>Verifies that single endpoint retries indefinitely no failover.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Task4_SingleEndpoint_RetriesIndefinitely_NoFailover()
{
@@ -275,6 +288,8 @@ public class DataConnectionActorTests : TestKit
_mockFactory.DidNotReceive().Create(Arg.Any<string>(), Arg.Any<IDictionary<string, string>>());
}
/// <summary>Verifies that round robin back to primary after backup fails.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Task4_RoundRobin_BackToPrimary_AfterBackupFails()
{
@@ -352,6 +367,8 @@ public class DataConnectionActorTests : TestKit
TimeSpan.FromSeconds(5));
}
/// <summary>Verifies that successful reconnect resets failure counter.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Task4_SuccessfulReconnect_ResetsFailureCounter()
{
@@ -406,6 +423,8 @@ public class DataConnectionActorTests : TestKit
_mockFactory.DidNotReceive().Create(Arg.Any<string>(), Arg.Any<IDictionary<string, string>>());
}
/// <summary>Verifies that re subscribe all called after failover reconnect.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Task4_ReSubscribeAll_CalledAfterFailoverReconnect()
{
@@ -485,8 +504,10 @@ public class DataConnectionActorTests : TestKit
"sensor/temp", Arg.Any<SubscriptionCallback>(), Arg.Any<CancellationToken>());
}
// ── PLAN-03 Task 16 (S7): background tasks must use the adapter that started them ──
// ── Background tasks must use the adapter that started them ──
/// <summary>Verifies that in flight subscribe continues on original adapter after failover.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Task16_InFlightSubscribe_ContinuesOnOriginalAdapter_AfterFailover()
{
@@ -545,7 +566,7 @@ public class DataConnectionActorTests : TestKit
backupAdapter.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ConnectAsync"),
TimeSpan.FromSeconds(5));
// Demand is registered before I/O (DC-1), so failover independently recovers
// Demand is registered before I/O, so failover independently recovers
// both tags on backup even while the original primary operation is parked.
AwaitCondition(() => backupAdapter.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "SubscribeAsync") == 2,
TimeSpan.FromSeconds(5));
@@ -562,7 +583,7 @@ public class DataConnectionActorTests : TestKit
Assert.Equal(2, backupAdapter.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "SubscribeAsync"));
}
// ── PLAN-03 Task 17 (P3): tag→instances reverse index for the value fan-out hot path ──
// ── Tag→instances reverse index for the value fan-out hot path ──
private IActorRef CreateInstantSubscribeActor(string name)
{
@@ -578,6 +599,8 @@ public class DataConnectionActorTests : TestKit
return CreateConnectionActor(name);
}
/// <summary>Verifies that tag fan out after one instance unsubscribes only remaining subscriber receives.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Task17_TagFanOut_AfterOneInstanceUnsubscribes_OnlyRemainingSubscriberReceives()
{
@@ -608,6 +631,8 @@ public class DataConnectionActorTests : TestKit
probeA.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>Verifies that tag fan out two instances disjoint tags each receives only its own.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Task17_TagFanOut_TwoInstancesDisjointTags_EachReceivesOnlyItsOwn()
{
@@ -631,7 +656,7 @@ public class DataConnectionActorTests : TestKit
probeB.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
// ── DataConnectionLayer-001: subscribe must not mutate actor state off-thread ──
// ── Subscribe must not mutate actor state off-thread ──
private static async Task<string> DelayedSubscribeAsync()
{
@@ -641,10 +666,12 @@ public class DataConnectionActorTests : TestKit
return "sub-" + Guid.NewGuid().ToString("N");
}
/// <summary>Verifies that concurrent subscribes do not corrupt subscription counters.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DCL001_ConcurrentSubscribes_DoNotCorruptSubscriptionCounters()
{
// Regression test for DataConnectionLayer-001. HandleSubscribe used to mutate
// Regression test. HandleSubscribe used to mutate
// actor state (_subscriptionIds, _totalSubscribed, _resolvedTags, the per-instance
// HashSet) from a Task.Run background thread. Many concurrent subscribes then race
// on non-thread-safe Dictionary/HashSet and on non-atomic int++ — losing increments
@@ -684,12 +711,14 @@ public class DataConnectionActorTests : TestKit
Assert.Equal(instances * tagsPerInstance, report.ResolvedTags);
}
// ── DataConnectionLayer-004: subscribe-time failure classification ──
// ── Subscribe-time failure classification ──
/// <summary>Verifies that genuine tag resolution failure pushes bad quality to subscriber.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DCL004_GenuineTagResolutionFailure_PushesBadQualityToSubscriber()
{
// Regression test for DataConnectionLayer-004. When a tag genuinely fails to
// Regression test. When a tag genuinely fails to
// resolve at subscribe time, the design doc (Tag Path Resolution, step 2)
// requires the attribute to be marked quality `bad`. The pre-fix code only
// logged and added the tag to _unresolvedTags — the Instance Actor never got
@@ -718,10 +747,12 @@ public class DataConnectionActorTests : TestKit
Assert.True(ack.Success);
}
/// <summary>Verifies that static tag seed value is delivered after registration.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DCL026_StaticTagSeedValue_IsDeliveredAfterRegistration()
{
// Regression test for DataConnectionLayer-026. The initial-read seed value used to
// Regression test. The initial-read seed value used to
// be emitted (TagValueReceived) from HandleSubscribe's background task BEFORE
// HandleSubscribeCompleted registered the instance's tags in
// _subscriptionsByInstance. HandleTagValueReceived's fan-out then found no
@@ -755,8 +786,10 @@ public class DataConnectionActorTests : TestKit
Assert.Equal("Left54321", update.Value);
}
// ── DataConnectionLayer-027: robust seeding for STATIC tags ──
// ── Robust seeding for STATIC tags ──
/// <summary>Verifies that seed read empty then good seeds tag within retry budget.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DCL027_SeedRead_EmptyThenGood_SeedsTagWithinRetryBudget()
{
@@ -792,6 +825,8 @@ public class DataConnectionActorTests : TestKit
Assert.Equal("HY12333", update.Value);
}
/// <summary>Verifies that seed read never returns value logs warning and still acks.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DCL027_SeedRead_NeverReturnsValue_LogsWarningAndStillAcks()
{
@@ -823,10 +858,12 @@ public class DataConnectionActorTests : TestKit
Assert.True(ack.Success);
}
/// <summary>Verifies that reconnect re seeds static tag.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DCL027_Reconnect_ReSeedsStaticTag()
{
// Mechanism #3: ReSubscribeAll re-advises after a reconnect but pre-fix did NO
// Mechanism three: ReSubscribeAll re-advises after a reconnect but pre-fix did NO
// seed read, so a STATIC tag (no further OnDataChange) could not self-heal across
// a reconnect/failover even though the source reads Good. After the fix the
// reconnect re-seeds the re-advised tags, so the post-reconnect value reaches the
@@ -873,10 +910,12 @@ public class DataConnectionActorTests : TestKit
Assert.Equal(99.0, reseeded.Value);
}
/// <summary>Verifies that connection level subscribe failure triggers reconnect not tag retry.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DCL004_ConnectionLevelSubscribeFailure_TriggersReconnect_NotTagRetry()
{
// Regression test for DataConnectionLayer-004. A subscribe failing because the
// Regression test. A subscribe failing because the
// adapter is not connected (InvalidOperationException from EnsureConnected) is
// a connection problem, not a bad tag path. The pre-fix code misclassified it
// as an unresolved tag and retried it on the 10s tag-resolution timer. After
@@ -904,12 +943,14 @@ public class DataConnectionActorTests : TestKit
TimeSpan.FromSeconds(5));
}
// ── DataConnectionLayer-005: WriteTimeout must bound a hung write ──
// ── WriteTimeout must bound a hung write ──
/// <summary>Verifies that write that hangs times out and returns failure synchronously.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DCL005_Write_ThatHangs_TimesOutAndReturnsFailureSynchronously()
{
// Regression test for DataConnectionLayer-005. HandleWrite called WriteAsync
// Regression test. HandleWrite called WriteAsync
// with no CancellationToken and no timeout, so a hung device write never
// produced a WriteTagResponse. The calling script would block until its own
// Ask-timeout with no DCL-level error. After the fix, _options.WriteTimeout
@@ -939,12 +980,14 @@ public class DataConnectionActorTests : TestKit
Assert.Contains("timeout", response.ErrorMessage, StringComparison.OrdinalIgnoreCase);
}
// ── DataConnectionLayer-006: quality counters must not drift after unsubscribe/reconnect ──
// ── Quality counters must not drift after unsubscribe/reconnect ──
/// <summary>Verifies that disconnect after unsubscribe bad quality count matches remaining tags.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DCL006_DisconnectAfterUnsubscribe_BadQualityCountMatchesRemainingTags()
{
// Regression test for DataConnectionLayer-006. _lastTagQuality and the three
// Regression test. _lastTagQuality and the three
// quality counters were never cleaned up on unsubscribe, so a tag removed via
// HandleUnsubscribe lingered in _lastTagQuality. PushBadQualityForAllTags then
// set _tagsBadQuality = _lastTagQuality.Count, counting the dropped tag and
@@ -1008,12 +1051,14 @@ public class DataConnectionActorTests : TestKit
reconnectGate.SetCanceled();
}
// ── DataConnectionLayer-010: tag-resolution retry must not double-dispatch ──
// ── Tag-resolution retry must not double-dispatch ──
/// <summary>Verifies that tag resolution retry does not issue duplicate concurrent subscribes.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DCL010_TagResolutionRetry_DoesNotIssueDuplicateConcurrentSubscribes()
{
// Regression test for DataConnectionLayer-010. HandleRetryTagResolution fired a
// Regression test. HandleRetryTagResolution fired a
// SubscribeAsync for every unresolved tag without removing it from _unresolvedTags
// first. A slow SubscribeAsync overlapping the next retry tick produced duplicate
// concurrent subscribe attempts for the same tag, leaking the first monitored
@@ -1057,12 +1102,14 @@ public class DataConnectionActorTests : TestKit
subscribeGate.SetCanceled();
}
// ── DataConnectionLayer-018: concurrent same-tag subscribes must not orphan adapter handles ──
// ── Concurrent same-tag subscribes must not orphan adapter handles ──
/// <summary>Verifies that concurrent subscribes same tag different instances issue one adapter subscribe.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DCL018_ConcurrentSubscribes_SameTag_DifferentInstances_IssueOneAdapterSubscribe()
{
// Regression test for DataConnectionLayer-018. Before the fix, HandleSubscribe
// Regression test. Before the fix, HandleSubscribe
// snapshotted _subscriptionIds.Keys on the actor thread BEFORE the Task.Run
// I/O. Two SubscribeTagsRequest messages for different instances sharing a tag
// would both observe "not subscribed" (the first request's SubscribeCompleted
@@ -1130,17 +1177,19 @@ public class DataConnectionActorTests : TestKit
actor.Tell(new DataConnectionActor.GetHealthReport());
var report = ExpectMsg<DataConnectionHealthReport>(TimeSpan.FromSeconds(5));
// One tag shared across two instances must count as exactly one subscription.
// DCL-020 (#28) tracks the related "previously-unresolved tag promoted via
// A separate scenario covers the related "previously-unresolved tag promoted via
// another instance" double-count case; here the tag was never unresolved.
Assert.Equal(1, report.ResolvedTags);
}
// ── DataConnectionLayer-020: previously-unresolved tag, resolved via different instance, must not double-count ──
// ── Previously-unresolved tag, resolved via different instance, must not double-count ──
/// <summary>Verifies that unresolved tag promoted by different instance does not double count total subscribed.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DCL020_UnresolvedTagPromoted_ByDifferentInstance_DoesNotDoubleCountTotalSubscribed()
{
// Regression test for DataConnectionLayer-020. The first SubscribeTagsRequest
// Regression test. The first SubscribeTagsRequest
// (instance A, tag "promote/tag") fails at the resolution layer — the tag is
// added to _unresolvedTags AND _totalSubscribed is bumped to 1. The second
// SubscribeTagsRequest (instance B, same tag) succeeds the adapter call. Before
@@ -1185,10 +1234,12 @@ public class DataConnectionActorTests : TestKit
Assert.Equal(1, report.ResolvedTags);
}
/// <summary>Verifies that two instances failing same tag only counts tag once in total.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DCL020_TwoInstancesFailingSameTag_OnlyCountsTagOnceInTotal()
{
// Regression test for DataConnectionLayer-020's symmetric failure branch.
// Regression test for the symmetric failure branch of the same scenario.
// Two instances both fail to resolve the same tag — _unresolvedTags must hold
// a single entry and _totalSubscribed must be 1, not 2. Pre-fix the failure
// branch always ran _totalSubscribed++, double-counting on the second
@@ -1221,12 +1272,14 @@ public class DataConnectionActorTests : TestKit
Assert.Equal(0, report.ResolvedTags);
}
// ── DataConnectionLayer-021: mid-flight unsubscribe must release adapter handle and drop state ──
// ── Mid-flight unsubscribe must release adapter handle and drop state ──
/// <summary>Verifies that unsubscribe during in flight subscribe releases adapter handle and keeps state clean.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DCL021_UnsubscribeDuringInFlightSubscribe_ReleasesAdapterHandle_AndKeepsStateClean()
{
// Regression test for DataConnectionLayer-021. Previously HandleSubscribeCompleted
// Regression test. Previously HandleSubscribeCompleted
// re-created _subscriptionsByInstance[instanceName] when the instance had been
// unsubscribed while the subscribe I/O was in flight, and then ran the same
// counter/handle mutations as the happy path. The leak permanently inflated
@@ -1280,12 +1333,14 @@ public class DataConnectionActorTests : TestKit
Assert.Equal(0, report.ResolvedTags);
}
// ── DataConnectionLayer-022: tag-resolution retry timer must not reset on every failure ──
// ── Tag-resolution retry timer must not reset on every failure ──
/// <summary>Verifies that bursted failed subscribes do not reset retry timer.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DCL022_BurstedFailedSubscribes_DoNotResetRetryTimer()
{
// Regression test for DataConnectionLayer-022. Both HandleSubscribeCompleted
// Regression test. Both HandleSubscribeCompleted
// and HandleTagResolutionFailed previously called Timers.StartPeriodicTimer
// unconditionally — StartPeriodicTimer with an existing key cancels and
// replaces the prior timer, so a burst of SubscribeTagsRequests arriving
@@ -1357,12 +1412,14 @@ public class DataConnectionActorTests : TestKit
$"This is the DCL-022 reset-on-every-call starvation regression.");
}
// ── DataConnectionLayer-011: stale callbacks from a disposed adapter must be dropped ──
// ── Stale callbacks from a disposed adapter must be dropped ──
/// <summary>Verifies that stale tag value from old adapter is not forwarded after failover.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DCL011_StaleTagValueFromOldAdapter_IsNotForwardedAfterFailover()
{
// Regression test for DataConnectionLayer-011. On failover the old adapter is
// Regression test. On failover the old adapter is
// disposed and a fresh one created, but the old adapter's subscription callbacks
// captured Self and keep Telling TagValueReceived. With no per-adapter generation
// tag, a value from the disposed adapter delivered after the actor is Connected
@@ -1430,12 +1487,14 @@ public class DataConnectionActorTests : TestKit
ExpectNoMsg(TimeSpan.FromSeconds(1));
}
// ── DataConnectionLayer-015: initial-connect failures must trigger failover ──
// ── Initial-connect failures must trigger failover ──
/// <summary>Verifies that primary down at startup fails over to backup.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DCL015_PrimaryDownAtStartup_FailsOverToBackup()
{
// Regression test for DataConnectionLayer-015. HandleConnectResult — the handler
// Regression test. HandleConnectResult — the handler
// for the INITIAL connection attempt in the Connecting state — only logged and
// re-armed the reconnect timer. It never incremented _consecutiveFailures and
// never switched endpoint, so a primary that is unreachable when the actor first
@@ -1470,6 +1529,8 @@ public class DataConnectionActorTests : TestKit
TimeSpan.FromSeconds(5));
}
/// <summary>Verifies that single endpoint down at startup retries indefinitely no failover.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DCL015_SingleEndpointDownAtStartup_RetriesIndefinitely_NoFailover()
{
@@ -1495,12 +1556,14 @@ public class DataConnectionActorTests : TestKit
_mockFactory.DidNotReceive().Create(Arg.Any<string>(), Arg.Any<IDictionary<string, string>>());
}
// ── DataConnectionLayer-016: subscribe response must reflect a connection-level failure ──
// ── Subscribe response must reflect a connection-level failure ──
/// <summary>Verifies that connection level subscribe failure replies with unsuccessful response.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DCL016_ConnectionLevelSubscribeFailure_RepliesWithUnsuccessfulResponse()
{
// Regression test for DataConnectionLayer-016. When a subscribe arrives while the
// Regression test. When a subscribe arrives while the
// adapter is silently down, HandleSubscribeCompleted drove the actor into
// Reconnecting (a connection-level failure) but still replied to the caller with
// SubscribeTagsResponse(Success: true, Error: null). The Instance Actor was told
@@ -1530,6 +1593,8 @@ public class DataConnectionActorTests : TestKit
Assert.NotNull(response.ErrorMessage);
}
/// <summary>Verifies that genuine resolution failure still replies success.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DCL016_GenuineResolutionFailure_StillRepliesSuccess()
{
@@ -1556,6 +1621,8 @@ public class DataConnectionActorTests : TestKit
Assert.Null(ack.ErrorMessage);
}
/// <summary>Verifies that subscribe with failed tags counts resolved and unresolved separately.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DCL001_SubscribeWithFailedTags_CountsResolvedAndUnresolvedSeparately()
{
@@ -1579,7 +1646,7 @@ public class DataConnectionActorTests : TestKit
["good/a", "good/b", "good/c", "bad/x", "bad/y"], DateTimeOffset.UtcNow));
// Two genuine resolution failures now also push a bad-quality TagValueUpdate
// to the subscriber (DataConnectionLayer-004); skip past those to the ack.
// to the subscriber; skip past those to the ack.
var ack = FishForMessage<SubscribeTagsResponse>(_ => true, TimeSpan.FromSeconds(5));
Assert.True(ack.Success);
@@ -1590,12 +1657,14 @@ public class DataConnectionActorTests : TestKit
Assert.Equal(3, report.ResolvedTags); // only the 3 good ones resolved
}
// ── DataConnectionLayer-008: HandleUnsubscribe shared-tag reference counting ──
// ── HandleUnsubscribe shared-tag reference counting ──
/// <summary>Verifies that unsubscribe only releases tag when last subscriber leaves.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DCL008_Unsubscribe_OnlyReleasesTagWhenLastSubscriberLeaves()
{
// Regression test for DataConnectionLayer-008. HandleUnsubscribe must release a
// Regression test. HandleUnsubscribe must release a
// tag at the adapter only when no other instance still subscribes to it. The
// O(n) per-tag scan over every instance was replaced with an O(1) reference
// count; this guards that the reference count tracks shared subscriptions
@@ -1649,14 +1718,16 @@ public class DataConnectionActorTests : TestKit
Assert.Equal(0, report2.ResolvedTags);
}
// ── DataConnectionLayer-027: a reconnect re-seed read that lands after a SECOND
// ── A reconnect re-seed read that lands after a SECOND
// failover (generation bumped twice) must be DROPPED, not applied ──
/// <summary>Verifies that stale generation reseed after second failover is dropped.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DCL027_StaleGenerationReseed_AfterSecondFailover_IsDropped()
{
// Regression/invariant pin for DataConnectionLayer-027 (the reconnect re-seed in
// ReSubscribeAll). The re-seed captures the adapter generation at kickoff and
// Regression/invariant pin for the reconnect re-seed in
// ReSubscribeAll. The re-seed captures the adapter generation at kickoff and
// delivers the seed value through the generation-guarded TagValueReceived path:
//
// var generation = _adapterGeneration; // captured at reseed kickoff
@@ -1712,7 +1783,7 @@ public class DataConnectionActorTests : TestKit
backupAdapter.ReadAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(_ =>
{
// Signal the test that the generation-1 re-seed read has been entered
// Signal the test that the generation 1 re-seed read has been entered
// (so generation 1 is definitely captured), then hand back the gated task.
backupReadEntered.TrySetResult();
return staleSeedReadGate.Task;
@@ -1720,7 +1791,7 @@ public class DataConnectionActorTests : TestKit
// ── Primary-again (generation 2): the round-robin target of the SECOND failover.
// Connects cleanly; its re-seed read returns a DISTINCT fresh value (77.0) so we
// can prove the FRESH generation-2 value IS accepted while the stale gen-1 is not. ──
// can prove the FRESH generation 2 value IS accepted while the stale gen 1 is not. ──
primaryAgainAdapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
primaryAgainAdapter.Status.Returns(ConnectionHealth.Connected);
@@ -1755,12 +1826,12 @@ public class DataConnectionActorTests : TestKit
RaiseDisconnected(primaryAdapter);
ExpectMsg<ConnectionQualityChanged>(TimeSpan.FromSeconds(3)); // bad-quality push on disconnect
// Wait until the generation-1 re-seed read has been ENTERED (generation 1 captured,
// Wait until the generation 1 re-seed read has been ENTERED (generation 1 captured,
// read pending on the gate). This anchors "generation N captured at reseed kickoff".
// WaitAsync throws TimeoutException (failing the test) if the read is never entered.
await backupReadEntered.Task.WaitAsync(TimeSpan.FromSeconds(5));
// ── SECOND failover (while the gen-1 re-seed read is still blocked): disconnect
// ── SECOND failover (while the gen 1 re-seed read is still blocked): disconnect
// backup → unstable → failover round-robin back to primary config (generation
// bumps 1 → 2). After this the actor's current generation is 2. ──
RaiseDisconnected(backupAdapter);
@@ -1774,16 +1845,16 @@ public class DataConnectionActorTests : TestKit
TimeSpan.FromSeconds(5));
await Task.Delay(200); // actor Connected on primary-again (generation 2)
// The FRESH generation-2 re-seed (read 77.0 on primary-again) is the only value
// The FRESH generation 2 re-seed (read 77.0 on primary-again) is the only value
// that should be applied. It may arrive before or after we release the stale seed;
// fish for it specifically and assert the stale value is never among the updates.
// ── Now release the gated generation-1 backup re-seed read. The continuation
// ── Now release the gated generation 1 backup re-seed read. The continuation
// Tells TagValueReceived(generation = 1); the actor's generation is now 2, so
// HandleTagValueReceived's guard MUST drop it. ──
staleSeedReadGate.SetResult(
new ReadResult(true, new TagValue(staleValue, QualityCode.Good, DateTimeOffset.UtcNow), null));
// The fresh generation-2 value (77.0) is accepted and reaches the subscriber;
// The fresh generation 2 value (77.0) is accepted and reaches the subscriber;
// FishForMessage will fail the test outright if it ever sees the stale 11.0.
var fresh = FishForMessage<TagValueUpdate>(
m =>
@@ -1801,6 +1872,8 @@ public class DataConnectionActorTests : TestKit
// ── Deferred register row 55: first-subscribe resolution counts never reached the collector ──
/// <summary>Verifies that first subscribe all tags resolve pushes resolution counts to health collector.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FirstSubscribe_AllTagsResolve_PushesResolutionCountsToHealthCollector()
{
@@ -1839,7 +1912,7 @@ public class DataConnectionActorTests : TestKit
_mockHealthCollector.Received().UpdateTagResolution("row55-first-subscribe", 0, 0);
// One instance subscribes to two tags; both resolve on the first attempt, so the
// batch/probe path never runs — exactly the rig's rig-probe-1 shape.
// batch/probe path never runs — exactly the rig's rig probe 1 shape.
actor.Tell(new SubscribeTagsRequest(
"c1", "inst1", "row55-first-subscribe",
["sensor/temp", "sensor/pressure"], DateTimeOffset.UtcNow));
@@ -4,10 +4,11 @@ using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
/// <summary>
/// WP-34: Tests for protocol extensibility via DataConnectionFactory.
/// Tests for protocol extensibility via DataConnectionFactory.
/// </summary>
public class DataConnectionFactoryTests
{
/// <summary>Verifies that creating an OPC UA connection returns the OPC UA adapter.</summary>
[Fact]
public void Create_OpcUa_ReturnsOpcUaAdapter()
{
@@ -18,6 +19,7 @@ public class DataConnectionFactoryTests
Assert.IsType<OpcUaDataConnection>(connection);
}
/// <summary>Verifies that creating an MxGateway connection returns the MxGateway adapter.</summary>
[Fact]
public void Create_MxGateway_ReturnsMxGatewayAdapter()
{
@@ -28,6 +30,7 @@ public class DataConnectionFactoryTests
Assert.IsType<MxGatewayDataConnection>(connection);
}
/// <summary>Verifies that protocol names are matched case-insensitively.</summary>
[Fact]
public void Create_CaseInsensitive()
{
@@ -38,6 +41,7 @@ public class DataConnectionFactoryTests
Assert.IsType<OpcUaDataConnection>(connection);
}
/// <summary>Verifies that an unknown protocol throws an exception.</summary>
[Fact]
public void Create_UnknownProtocol_Throws()
{
@@ -50,12 +54,13 @@ public class DataConnectionFactoryTests
Assert.Contains("OpcUa", ex.Message);
}
/// <summary>Verifies that a registered adapter extends the set of protocols the factory can create.</summary>
[Fact]
public void RegisterAdapter_ExtendsFactory()
{
var factory = new DataConnectionFactory(NullLoggerFactory.Instance);
// WP-34: Adding new protocol = register adapter
// Adding a new protocol = register an adapter
factory.RegisterAdapter("Custom", _ => new OpcUaDataConnection(
new DefaultOpcUaClientFactory(), NullLogger<OpcUaDataConnection>.Instance));
@@ -10,7 +10,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
/// <summary>
/// WP-34: Tests for DataConnectionManagerActor routing and lifecycle.
/// Tests for DataConnectionManagerActor routing and lifecycle.
/// </summary>
[Collection("DataConnectionManagerActor")]
public class DataConnectionManagerActorTests : TestKit
@@ -19,6 +19,7 @@ public class DataConnectionManagerActorTests : TestKit
private readonly DataConnectionOptions _options;
private readonly ISiteHealthCollector _mockHealthCollector;
/// <summary>Initializes the test class with mock collaborators and the actor-system configuration.</summary>
public DataConnectionManagerActorTests()
: base(@"akka.loglevel = DEBUG")
{
@@ -31,6 +32,7 @@ public class DataConnectionManagerActorTests : TestKit
};
}
/// <summary>Verifies that writing to an unknown connection returns an error.</summary>
[Fact]
public void WriteToUnknownConnection_ReturnsError()
{
@@ -45,6 +47,7 @@ public class DataConnectionManagerActorTests : TestKit
Assert.Contains("Unknown connection", response.ErrorMessage);
}
/// <summary>Verifies that subscribing to an unknown connection returns an error.</summary>
[Fact]
public void SubscribeToUnknownConnection_ReturnsError()
{
@@ -59,6 +62,7 @@ public class DataConnectionManagerActorTests : TestKit
Assert.Contains("Unknown connection", response.ErrorMessage);
}
/// <summary>Verifies that subscribing to alarms on an unknown connection returns an error.</summary>
[Fact]
public void SubscribeAlarmsToUnknownConnection_ReturnsError()
{
@@ -76,10 +80,11 @@ public class DataConnectionManagerActorTests : TestKit
Assert.Contains("Unknown connection", response.ErrorMessage);
}
/// <summary>Verifies that a connection actor crash preserves the subscription state.</summary>
[Fact]
public void DCL002_ConnectionActorCrash_PreservesSubscriptionState()
{
// Regression test for DataConnectionLayer-002. The supervisor used
// Regression test: the supervisor used
// Directive.Restart, which discards the connection actor's in-memory
// subscription registry — breaking the design doc's "transparent
// re-subscribe" guarantee (subscribers are never re-subscribed and sit at
@@ -133,6 +138,7 @@ public class DataConnectionManagerActorTests : TestKit
Assert.Equal(1, report.ResolvedTags);
}
/// <summary>Verifies that creating a connection uses the connection factory.</summary>
[Fact]
public void CreateConnection_UsesFactory()
{
@@ -13,6 +13,7 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
/// </summary>
public class DataConnectionOptionsBindingTests
{
/// <summary>Verifies that add data connection layer binds the scada bridge data connection section.</summary>
[Fact]
public void AddDataConnectionLayer_BindsTheScadaBridgeDataConnectionSection()
{
@@ -14,6 +14,7 @@ public class DataConnectionOptionsValidatorTests
private static ValidateOptionsResult Validate(DataConnectionOptions options) =>
new DataConnectionOptionsValidator().Validate(Options.DefaultName, options);
/// <summary>Verifies that the default options are valid.</summary>
[Fact]
public void DefaultOptions_AreValid()
{
@@ -21,6 +22,7 @@ public class DataConnectionOptionsValidatorTests
Assert.True(result.Succeeded, result.FailureMessage);
}
/// <summary>Verifies that a zero reconnect interval is rejected.</summary>
[Fact]
public void ZeroReconnectInterval_IsRejected()
{
@@ -29,6 +31,7 @@ public class DataConnectionOptionsValidatorTests
Assert.Contains("ReconnectInterval", result.FailureMessage);
}
/// <summary>Verifies that a non-positive seed-read max attempts value is rejected.</summary>
[Fact]
public void NonPositiveSeedReadMaxAttempts_IsRejected()
{
@@ -6,6 +6,8 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
public class GlobalOptionsValidationTests
{
/// <summary>Verifies that startup rejects blank identity.</summary>
/// <param name="key">Test case input.</param>
[Theory]
[InlineData("ScadaBridge:OpcUa:ApplicationName")]
[InlineData("ScadaBridge:MxGateway:ClientNamePrefix")]
@@ -20,6 +22,7 @@ public class GlobalOptionsValidationTests
Assert.Contains(key, exception.Message);
}
/// <summary>Verifies that defaults validate at startup.</summary>
[Fact]
public void DefaultsValidateAtStartup()
{
@@ -8,9 +8,10 @@ using ProtoTransitionKind = ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmTransitionK
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
/// <summary>Task-12: pure MxGateway alarm-feed proto → NativeAlarmTransition mapping.</summary>
/// <summary>Pure MxGateway alarm-feed proto → NativeAlarmTransition mapping.</summary>
public class MxGatewayAlarmMapperTests
{
/// <summary>Verifies that an acknowledge transition is mapped as acknowledged with the operator.</summary>
[Fact]
public void MapTransition_AckTransition_IsAcknowledgedWithOperator()
{
@@ -37,6 +38,7 @@ public class MxGatewayAlarmMapperTests
Assert.Equal("Tank01", t.SourceObjectReference);
}
/// <summary>Verifies that an active, acknowledged condition state maps to active true and acknowledged true.</summary>
[Fact]
public void MapConditionState_ActiveAcked_To_ActiveTrue_AckTrue()
{
@@ -46,6 +48,7 @@ public class MxGatewayAlarmMapperTests
Assert.Equal(600, c.Severity);
}
/// <summary>Verifies that an active, unacknowledged snapshot maps to the snapshot kind.</summary>
[Fact]
public void MapSnapshot_ActiveUnacked_IsSnapshotKind()
{
@@ -66,6 +69,7 @@ public class MxGatewayAlarmMapperTests
Assert.Equal(1000, t.Condition.Severity);
}
/// <summary>Verifies that the source reference is object-relative rather than the full provider reference.</summary>
[Fact]
public void SourceReference_IsObjectRelative_NotFullProviderReference()
{
@@ -98,6 +102,7 @@ public class MxGatewayAlarmMapperTests
Assert.Equal("CVDAisle_1", snapT.Category);
}
/// <summary>Verifies that the source reference falls back to the full reference when the object reference is empty.</summary>
[Fact]
public void SourceReference_FallsBackToFullReference_WhenObjectReferenceEmpty()
{
@@ -114,8 +119,9 @@ public class MxGatewayAlarmMapperTests
Assert.Equal("Galaxy!Area.Obj.Alarm", t.SourceReference);
}
// ── CurrentValue / LimitValue (M2.13 / #27) ──────────────────────────────
// ── CurrentValue / LimitValue ──────────────────────────────
/// <summary>Verifies that the current and limit values of a transition are populated from the proto.</summary>
[Fact]
public void MapTransition_CurrentAndLimitValue_PopulatedFromProto()
{
@@ -139,6 +145,7 @@ public class MxGatewayAlarmMapperTests
Assert.Equal("90", t.LimitValue);
}
/// <summary>Verifies that a transition with no current or limit value yields empty values.</summary>
[Fact]
public void MapTransition_AbsentCurrentAndLimitValue_YieldsEmpty()
{
@@ -160,6 +167,7 @@ public class MxGatewayAlarmMapperTests
Assert.Equal("", t.LimitValue);
}
/// <summary>Verifies that the current and limit values of a snapshot are populated from the proto.</summary>
[Fact]
public void MapSnapshot_CurrentAndLimitValue_PopulatedFromProto()
{
@@ -181,6 +189,7 @@ public class MxGatewayAlarmMapperTests
Assert.Equal("10", t.LimitValue);
}
/// <summary>Verifies that a string MX value produces a string current value.</summary>
[Fact]
public void MapSnapshot_StringMxValue_ProducesStringCurrentValue()
{
@@ -206,6 +215,7 @@ public class MxGatewayAlarmMapperTests
private static readonly DateTimeOffset GatewayTime =
new(2026, 8, 1, 9, 30, 0, TimeSpan.Zero);
/// <summary>Verifies that an acknowledge transition stamps the observed transition time as the ack time.</summary>
[Fact]
public void MapTransition_Acknowledge_StampsTheObservedTransitionTimeAsAckTime()
{
@@ -227,6 +237,7 @@ public class MxGatewayAlarmMapperTests
Assert.Equal(t.TransitionTime, t.AckTime);
}
/// <summary>Verifies that a raise transition has no ack time.</summary>
[Fact]
public void MapTransition_Raise_HasNoAckTime()
{
@@ -243,6 +254,7 @@ public class MxGatewayAlarmMapperTests
Assert.Null(MxGatewayAlarmMapper.MapTransition(ev).AckTime);
}
/// <summary>Verifies that a retrigger transition clears the ack time.</summary>
[Fact]
public void MapTransition_Retrigger_ClearsTheAckTime()
{
@@ -260,6 +272,7 @@ public class MxGatewayAlarmMapperTests
Assert.Null(MxGatewayAlarmMapper.MapTransition(ev).AckTime);
}
/// <summary>Verifies that a clear transition has no ack time despite the inactive state mapping to acknowledged.</summary>
[Fact]
public void MapTransition_Clear_HasNoAckTime_DespiteInactiveMappingToAcked()
{
@@ -280,6 +293,7 @@ public class MxGatewayAlarmMapperTests
Assert.Null(t.AckTime); // …but no ack was observed
}
/// <summary>Verifies that an active, acknowledged snapshot restores an ack time from the last transition.</summary>
[Fact]
public void MapSnapshot_ActiveAcked_RestoresAnAckTimeFromTheLastTransition()
{
@@ -297,6 +311,7 @@ public class MxGatewayAlarmMapperTests
Assert.Equal(GatewayTime, MxGatewayAlarmMapper.MapSnapshot(snap).AckTime);
}
/// <summary>Verifies that an active, unacknowledged snapshot has no ack time.</summary>
[Fact]
public void MapSnapshot_ActiveUnacked_HasNoAckTime()
{
@@ -312,6 +327,7 @@ public class MxGatewayAlarmMapperTests
Assert.Null(MxGatewayAlarmMapper.MapSnapshot(snap).AckTime);
}
/// <summary>Verifies that the snapshot-complete sentinel carries no ack time.</summary>
[Fact]
public void SnapshotComplete_Sentinel_CarriesNoAckTime()
{
@@ -7,7 +7,7 @@ using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
/// <summary>
/// Task 28: live smoke test for the OPC UA Alarms &amp; Conditions adapter
/// Live smoke test for the OPC UA Alarms &amp; Conditions adapter
/// (<see cref="OpcUaDataConnection"/> as <c>IAlarmSubscribableConnection</c>).
///
/// Round-trips against an alarm-capable OPC UA endpoint
@@ -28,6 +28,8 @@ public class OpcUaAlarmLiveSmokeTests
{
private const string EndpointUrl = "opc.tcp://localhost:50000";
/// <summary>Verifies that subscribing to alarms delivers a condition refresh snapshot.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task SubscribeAlarms_DeliversConditionRefreshSnapshot()
{
@@ -80,6 +82,8 @@ public class OpcUaAlarmLiveSmokeTests
}
}
/// <summary>Verifies that two boiler alarm sources keep distinct refresh scopes and epochs.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task TwoBoilerSources_KeepDistinctRefreshScopesAndEpochs()
{
@@ -3,15 +3,16 @@ using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
/// <summary>Task-11: pure OPC UA A&C field → AlarmConditionState/transition mapping.</summary>
/// <summary>Pure OPC UA A&C field → AlarmConditionState/transition mapping.</summary>
public class OpcUaAlarmMapperTests
{
// ── Gitea #17: routing identity must be the binding, not the event name ──
// ── Routing identity must be the binding, not the event name ──
/// <summary>Verifies that a NodeId binding routes on the binding rather than the event name.</summary>
[Fact]
public void BuildIdentity_NodeIdBinding_RoutesOnBindingNotName()
{
// OtOpcUa v3 post-#473: SourceName is the RawPath, ConditionName the leaf. The
// OtOpcUa v3 post-migration: SourceName is the RawPath, ConditionName the leaf. The
// binding is a durable node reference. The bug was SourceObjectReference = SourceName,
// which never prefix-matches a NodeId binding, so every transition was dropped.
var (sourceRef, sourceObjectRef) = OpcUaAlarmMapper.BuildIdentity(
@@ -25,6 +26,7 @@ public class OpcUaAlarmMapperTests
Assert.Equal("pymodbus/plc/HR200.HR200", sourceRef);
}
/// <summary>Verifies that a legacy index binding routes verbatim.</summary>
[Fact]
public void BuildIdentity_LegacyIndexBinding_RoutesVerbatim()
{
@@ -38,6 +40,7 @@ public class OpcUaAlarmMapperTests
Assert.Equal("ns=2;s=pymodbus/plc/HR200", sourceObjectRef);
}
/// <summary>Verifies that an aggregate feed keeps an empty identity.</summary>
[Fact]
public void BuildIdentity_AggregateFeed_StaysEmpty()
{
@@ -53,6 +56,7 @@ public class OpcUaAlarmMapperTests
Assert.Equal("pymodbus/plc/HR200.HR200", sourceRef);
}
/// <summary>Verifies that a missing condition name uses the source name alone.</summary>
[Fact]
public void BuildIdentity_NoConditionName_UsesSourceNameAlone()
{
@@ -64,6 +68,7 @@ public class OpcUaAlarmMapperTests
Assert.Equal("Tank01", sourceRef);
}
/// <summary>Verifies that a missing source name falls back to the binding for the key.</summary>
[Fact]
public void BuildIdentity_NoSourceName_FallsBackToBindingForKey()
{
@@ -77,6 +82,7 @@ public class OpcUaAlarmMapperTests
Assert.Equal("ns=2;s=X", sourceObjectRef);
}
/// <summary>Verifies that severity normalization clamps to the range 0 to 1000.</summary>
[Fact]
public void NormalizeSeverity_ClampsTo0_1000()
{
@@ -85,6 +91,7 @@ public class OpcUaAlarmMapperTests
Assert.Equal(500, OpcUaAlarmMapper.NormalizeSeverity(500));
}
/// <summary>Verifies that an active, unacknowledged event builds the expected condition state.</summary>
[Fact]
public void BuildCondition_ActiveUnacked()
{
@@ -96,6 +103,7 @@ public class OpcUaAlarmMapperTests
Assert.Equal(AlarmShelveState.Unshelved, c.Shelve);
}
/// <summary>Verifies that an acknowledge edge yields an Acknowledge transition.</summary>
[Fact]
public void DeriveKind_AckEdge_YieldsAcknowledge()
{
@@ -103,6 +111,7 @@ public class OpcUaAlarmMapperTests
OpcUaAlarmMapper.DeriveKind(prevAcked: false, nowAcked: true, prevActive: true, nowActive: true));
}
/// <summary>Verifies that an active edge yields a Raise transition.</summary>
[Fact]
public void DeriveKind_ActiveEdge_YieldsRaise()
{
@@ -110,6 +119,7 @@ public class OpcUaAlarmMapperTests
OpcUaAlarmMapper.DeriveKind(prevAcked: true, nowAcked: true, prevActive: false, nowActive: true));
}
/// <summary>Verifies that an inactive edge yields a Clear transition.</summary>
[Fact]
public void DeriveKind_InactiveEdge_YieldsClear()
{
@@ -117,6 +127,9 @@ public class OpcUaAlarmMapperTests
OpcUaAlarmMapper.DeriveKind(prevAcked: true, nowAcked: true, prevActive: true, nowActive: false));
}
/// <summary>Verifies that the current shelving state name maps to the expected shelve state.</summary>
/// <param name="name">The shelving state name reported by the source, or null.</param>
/// <param name="expected">The expected shelve state.</param>
[Theory]
[InlineData("OneShotShelved", AlarmShelveState.OneShotShelved)]
[InlineData("TimedShelved", AlarmShelveState.TimedShelved)]
@@ -127,8 +140,9 @@ public class OpcUaAlarmMapperTests
Assert.Equal(expected, OpcUaAlarmMapper.MapShelve(name));
}
// ── PickLimitValue (M2.13 / #27) ─────────────────────────────────────────
// ── PickLimitValue ─────────────────────────────────────────
/// <summary>Verifies that a set of all-null limits returns an empty value.</summary>
[Fact]
public void PickLimitValue_AllNull_ReturnsEmpty()
{
@@ -136,6 +150,7 @@ public class OpcUaAlarmMapperTests
Assert.Equal("", OpcUaAlarmMapper.PickLimitValue(null, null, null, null));
}
/// <summary>Verifies that a present high-high limit is returned.</summary>
[Fact]
public void PickLimitValue_HighHighLimitPresent_ReturnsIt()
{
@@ -144,6 +159,7 @@ public class OpcUaAlarmMapperTests
Assert.Equal("100.5", result);
}
/// <summary>Verifies that when only the high limit is present it is returned.</summary>
[Fact]
public void PickLimitValue_OnlyHighLimit_ReturnsHighLimit()
{
@@ -152,6 +168,7 @@ public class OpcUaAlarmMapperTests
Assert.Equal("80", result);
}
/// <summary>Verifies that the high-high limit wins over the high limit when both are present.</summary>
[Fact]
public void PickLimitValue_PriorityOrder_HighHighWinsOverHigh()
{
@@ -160,6 +177,7 @@ public class OpcUaAlarmMapperTests
Assert.Equal("95", result);
}
/// <summary>Verifies that when only the low-low limit is present it is returned.</summary>
[Fact]
public void PickLimitValue_OnlyLowLow_ReturnsLowLow()
{
@@ -168,6 +186,7 @@ public class OpcUaAlarmMapperTests
Assert.Equal("-10.5", result);
}
/// <summary>Verifies that limit values are formatted using the invariant culture.</summary>
[Fact]
public void PickLimitValue_UsesInvariantCulture()
{
@@ -184,6 +203,7 @@ public class OpcUaAlarmMapperTests
private static readonly DateTimeOffset Observed =
new(2026, 8, 1, 10, 5, 0, TimeSpan.Zero);
/// <summary>Verifies that an active, acknowledged alarm prefers the source acknowledgement time.</summary>
[Fact]
public void DeriveAckTime_ActiveAcked_PrefersTheSourcesOwnAckInstant()
{
@@ -195,6 +215,7 @@ public class OpcUaAlarmMapperTests
OpcUaAlarmMapper.DeriveAckTime(active: true, acked: true, SourceAck, Observed));
}
/// <summary>Verifies that an active, acknowledged alarm without a source time falls back to the observation time.</summary>
[Fact]
public void DeriveAckTime_ActiveAcked_WithoutSourceTime_FallsBackToObservationTime()
{
@@ -206,6 +227,7 @@ public class OpcUaAlarmMapperTests
OpcUaAlarmMapper.DeriveAckTime(active: true, acked: true, sourceAckTime: null, Observed));
}
/// <summary>Verifies that an unacknowledged alarm has no acknowledgement time even when the source reports an acknowledgement transition.</summary>
[Fact]
public void DeriveAckTime_Unacked_IsNull_EvenWhenSourceReportsAnAckTransition()
{
@@ -216,6 +238,7 @@ public class OpcUaAlarmMapperTests
active: true, acked: false, sourceAckTime: SourceAck, Observed));
}
/// <summary>Verifies that an inactive alarm has no acknowledgement time.</summary>
[Fact]
public void DeriveAckTime_Inactive_IsNull()
{
@@ -9,7 +9,7 @@ using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
/// <summary>
/// DataConnectionLayer-003: structural regression guard. RealOpcUaClient's
/// Structural regression guard. RealOpcUaClient's
/// monitored-item / callback maps are read from the OPC UA SDK's publish threads
/// concurrently with subscribe/disconnect mutations on other threads. They must be
/// concurrent collections, not plain Dictionary. This is verified structurally
@@ -18,6 +18,10 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
/// </summary>
public class RealOpcUaClientThreadSafetyTests
{
/// <summary>
/// Verifies that the shared dictionary fields of the OPC UA client are concurrent collections.
/// </summary>
/// <param name="fieldName">The name of the field that is inspected.</param>
[Theory]
[InlineData("_callbacks")]
[InlineData("_monitoredItems")]
@@ -40,7 +44,7 @@ public class RealOpcUaClientThreadSafetyTests
}
/// <summary>
/// DataConnectionLayer-019: <see cref="OpcUaDataConnection"/> previously kept a
/// <see cref="OpcUaDataConnection"/> previously kept a
/// dead <c>Dictionary&lt;string,string&gt; _subscriptionHandles</c> field that was
/// written and removed across thread-pool continuations but never read. Plain
/// Dictionary writes from concurrent post-await continuations are racy; the
@@ -53,6 +57,7 @@ public class RealOpcUaClientThreadSafetyTests
/// </summary>
public class OpcUaDataConnectionThreadSafetyTests
{
/// <summary>Verifies that the OPC UA data connection has no non-concurrent shared dictionary field.</summary>
[Fact]
public void DCL019_OpcUaDataConnection_HasNoNonConcurrentSharedDictionary()
{
@@ -76,14 +81,15 @@ public class OpcUaDataConnectionThreadSafetyTests
}
/// <summary>
/// DataConnectionLayer-012: secure-by-default certificate handling.
/// Secure-by-default certificate handling.
/// </summary>
public class OpcUaCertificateDefaultTests
{
/// <summary>Verifies that automatic acceptance of untrusted certificates defaults to false.</summary>
[Fact]
public void DCL012_OpcUaConnectionOptions_AutoAcceptUntrustedCerts_DefaultsToFalse()
{
// Regression test for DataConnectionLayer-012. AutoAcceptUntrustedCerts defaulted
// Regression test: AutoAcceptUntrustedCerts defaulted
// to true, accepting every server certificate unconditionally and defeating the
// Sign / SignAndEncrypt security modes against an active man-in-the-middle. A
// secure-by-default posture rejects untrusted certs unless explicitly opted in.
@@ -93,7 +99,7 @@ public class OpcUaCertificateDefaultTests
}
/// <summary>
/// DataConnectionLayer-014: the DCL-012 auto-accept-certificate security warning is
/// The auto-accept-certificate security warning is
/// only effective if RealOpcUaClient is built with a real logger. The only production
/// path that constructs one is RealOpcUaClientFactory.Create(); that factory must
/// thread a logger through, otherwise the warning sinks into NullLogger and an
@@ -109,12 +115,13 @@ public class RealOpcUaClientFactoryLoggerTests
return (ILogger<RealOpcUaClient>)field!.GetValue(client)!;
}
/// <summary>Verifies that the real OPC UA client factory creates a client with a real logger.</summary>
[Fact]
public void DCL014_RealOpcUaClientFactory_CreatesClientWithRealLogger()
{
// Regression test for DataConnectionLayer-014. RealOpcUaClientFactory.Create()
// Regression test: RealOpcUaClientFactory.Create()
// constructed `new RealOpcUaClient(_globalOptions)` with no logger, so the
// DCL-012 man-in-the-middle warning was always discarded by NullLogger in
// auto-accept man-in-the-middle warning was always discarded by NullLogger in
// production. After the fix the factory accepts an ILoggerFactory and passes a
// real ILogger<RealOpcUaClient> into every client it creates.
using var loggerFactory = LoggerFactory.Create(b => { });
@@ -126,6 +133,7 @@ public class RealOpcUaClientFactoryLoggerTests
Assert.NotSame(NullLogger<RealOpcUaClient>.Instance, logger);
}
/// <summary>Verifies that the data connection factory passes its logger through to the real OPC UA client.</summary>
[Fact]
public void DCL014_DataConnectionFactory_ThreadsLoggerToRealOpcUaClient()
{
@@ -150,14 +158,15 @@ public class RealOpcUaClientFactoryLoggerTests
}
/// <summary>
/// DataConnectionLayer-009: failover-stability tunables must be configurable.
/// Failover-stability tunables must be configurable.
/// </summary>
public class DataConnectionOptionsStabilityTests
{
/// <summary>Verifies that the stable connection threshold is configurable and defaults to sixty seconds.</summary>
[Fact]
public void DCL009_StableConnectionThreshold_IsConfigurable_WithSixtySecondDefault()
{
// Regression test for DataConnectionLayer-009. The unstable-disconnect failover
// Regression test: the unstable-disconnect failover
// path used a hard-coded 60s StableConnectionThreshold constant inside
// DataConnectionActor. It must live on DataConnectionOptions like the other
// tunables (ReconnectInterval, TagResolutionRetryInterval, WriteTimeout) so it
@@ -171,7 +180,7 @@ public class DataConnectionOptionsStabilityTests
}
/// <summary>
/// WP-7: Tests for OPC UA adapter.
/// Tests for the OPC UA adapter.
/// </summary>
public class OpcUaDataConnectionTests
{
@@ -179,6 +188,7 @@ public class OpcUaDataConnectionTests
private readonly IOpcUaClientFactory _mockFactory;
private readonly OpcUaDataConnection _adapter;
/// <summary>Initializes the mocked OPC UA client and the adapter under test.</summary>
public OpcUaDataConnectionTests()
{
_mockClient = Substitute.For<IOpcUaClient>();
@@ -187,6 +197,8 @@ public class OpcUaDataConnectionTests
_adapter = new OpcUaDataConnection(_mockFactory, NullLogger<OpcUaDataConnection>.Instance);
}
/// <summary>Verifies that connecting sets the status to connected.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Connect_SetsStatusToConnected()
{
@@ -201,6 +213,8 @@ public class OpcUaDataConnectionTests
await _mockClient.Received(1).ConnectAsync("opc.tcp://localhost:4840", Arg.Any<OpcUaConnectionOptions?>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that disconnecting sets the status to disconnected.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Disconnect_SetsStatusToDisconnected()
{
@@ -211,10 +225,12 @@ public class OpcUaDataConnectionTests
Assert.Equal(ConnectionHealth.Disconnected, _adapter.Status);
}
/// <summary>Verifies that concurrent connection-lost notifications raise the disconnected event exactly once.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DCL013_ConcurrentConnectionLost_RaisesDisconnectedExactlyOnce()
{
// Regression test for DataConnectionLayer-013. RaiseDisconnected used a
// Regression test: RaiseDisconnected used a
// non-atomic check-then-set on a volatile bool: two threads racing through it
// (e.g. the keep-alive thread and a ReadAsync failure path, both routed via
// OnClientConnectionLost) could both observe _disconnectFired == false and both
@@ -250,6 +266,8 @@ public class OpcUaDataConnectionTests
}
}
/// <summary>Verifies that subscribing delegates to the client and returns the subscription id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Subscribe_DelegatesAndReturnsId()
{
@@ -263,6 +281,8 @@ public class OpcUaDataConnectionTests
Assert.Equal("sub-001", subId);
}
/// <summary>Verifies that a successful write returns a good result.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Write_Success_ReturnsGoodResult()
{
@@ -277,6 +297,8 @@ public class OpcUaDataConnectionTests
Assert.Null(result.ErrorMessage);
}
/// <summary>Verifies that a failed write returns an error result.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Write_Failure_ReturnsError()
{
@@ -291,6 +313,8 @@ public class OpcUaDataConnectionTests
Assert.Contains("0x80000000", result.ErrorMessage);
}
/// <summary>Verifies that reading a value with a bad status returns a bad result.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Read_BadStatus_ReturnsBadResult()
{
@@ -304,6 +328,8 @@ public class OpcUaDataConnectionTests
Assert.False(result.Success);
}
/// <summary>Verifies that reading a value with a good status returns the value.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Read_GoodStatus_ReturnsValue()
{
@@ -320,10 +346,12 @@ public class OpcUaDataConnectionTests
Assert.Equal(QualityCode.Good, result.Value.Quality);
}
/// <summary>Verifies that a batch read returns a value for every requested tag.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReadBatch_ReadsAllTags()
{
// WP2.1b: ReadBatchAsync is TRUE bulk — ONE client ReadValuesAsync call carrying
// ReadBatchAsync is TRUE bulk — ONE client ReadValuesAsync call carrying
// every requested node, not a per-tag loop.
_mockClient.IsConnected.Returns(true);
_mockClient.ReadValuesAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>())
@@ -340,10 +368,12 @@ public class OpcUaDataConnectionTests
Assert.Single(_mockClient.ReceivedCalls(), c => c.GetMethodInfo().Name == "ReadValuesAsync");
}
/// <summary>Verifies that a batch read returns per-tag results when one tag fails.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DCL007_ReadBatch_ReturnsPerTagResults_WhenOneTagFails()
{
// Regression test for DataConnectionLayer-007. ReadBatchAsync originally looped
// Regression test: ReadBatchAsync originally looped
// calling ReadAsync per tag; ReadAsync re-throws any non-cancellation exception, so
// a single failing tag aborted the whole batch and the caller got NO results for
// the tags that did read successfully — even though ReadResult already carries a
@@ -372,13 +402,15 @@ public class OpcUaDataConnectionTests
Assert.NotNull(results["bad"].ErrorMessage);
}
/// <summary>Verifies that a batch write returns per-tag results when some tags fail.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DCL017_WriteBatch_ReturnsPerTagResults_WhenSomeTagsFail()
{
// Regression test for DataConnectionLayer-017. WriteBatchAsync originally looped
// Regression test: WriteBatchAsync originally looped
// calling WriteAsync per tag; a mid-batch fault made the whole call throw and the
// caller lost the per-tag outcomes for the tags that already wrote. The batch is
// now ONE bulk service call (WP2.1b), and the invariant is unchanged: per-node
// now ONE bulk service call, and the invariant is unchanged: per-node
// failures are reported as failed WriteResult rows and every requested tag is
// present in the returned map.
_mockClient.IsConnected.Returns(true);
@@ -411,6 +443,8 @@ public class OpcUaDataConnectionTests
Assert.Single(_mockClient.ReceivedCalls(), c => c.GetMethodInfo().Name == "WriteValuesAsync");
}
/// <summary>Verifies that cancellation aborts the whole batch write.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DCL017_WriteBatch_CancellationAbortsWholeBatch()
{
@@ -429,6 +463,8 @@ public class OpcUaDataConnectionTests
_adapter.WriteBatchAsync(new Dictionary<string, object?> { ["tag1"] = 1 }, cts.Token));
}
/// <summary>Verifies that operations throw when the adapter is not connected.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task NotConnected_ThrowsOnOperations()
{
@@ -438,6 +474,8 @@ public class OpcUaDataConnectionTests
_adapter.ReadAsync("tag1"));
}
/// <summary>Verifies that disposing the adapter cleans up its resources.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DisposeAsync_CleansUp()
{
@@ -450,6 +488,8 @@ public class OpcUaDataConnectionTests
// --- Configuration Parsing ---
/// <summary>Verifies that connecting parses every supported configuration key.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Connect_ParsesAllConfigurationKeys()
{
@@ -487,6 +527,8 @@ public class OpcUaDataConnectionTests
Arg.Any<CancellationToken>());
}
/// <summary>Verifies that connecting uses default values when the configuration keys are not provided.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Connect_UsesDefaults_WhenKeysNotProvided()
{
@@ -511,6 +553,8 @@ public class OpcUaDataConnectionTests
Arg.Any<CancellationToken>());
}
/// <summary>Verifies that connecting ignores invalid numeric configuration values.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Connect_IgnoresInvalidNumericValues()
{
@@ -535,6 +579,8 @@ public class OpcUaDataConnectionTests
Arg.Any<CancellationToken>());
}
/// <summary>Verifies that connecting parses the security mode configuration value.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Connect_ParsesSecurityMode()
{
@@ -551,6 +597,8 @@ public class OpcUaDataConnectionTests
Arg.Any<CancellationToken>());
}
/// <summary>Verifies that connecting parses the automatic certificate acceptance setting.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Connect_ParsesAutoAcceptCerts()
{
@@ -573,6 +621,8 @@ public class OpcUaDataConnectionTests
// one, leaking its session/keep-alive/socket per flap AND leaving its stale
// ConnectionLost handler wired — so a late keep-alive death on the OLD client
// flapped the NEW healthy session (after _disconnectFired was reset).
/// <summary>Verifies that reconnecting disposes the previous client and detaches its handlers.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reconnect_DisposesAndDetachesPreviousClient()
{
@@ -593,6 +643,8 @@ public class OpcUaDataConnectionTests
await clients[1].DidNotReceive().DisposeAsync();
}
/// <summary>Verifies that a connection-lost event from a stale client does not raise a disconnect after a reconnect.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StaleClientConnectionLost_DoesNotRaiseDisconnected_AfterReconnect()
{
@@ -4,13 +4,15 @@ using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
/// <summary>
/// M7-B1 (T16 type-info): pure NodeId → friendly built-in DataType name mapping.
/// Type-info: pure NodeId → friendly built-in DataType name mapping.
/// Known OPC UA built-in DataType NodeIds (ns=0;i=…) resolve to their CLR-ish
/// friendly names; unknown/custom DataType NodeIds fall back to the NodeId string.
/// No live server is required — this is a pure lookup helper.
/// </summary>
public class OpcUaDataTypeNameMapTests
{
/// <summary>Verifies that resolve, known built in types, return friendly names.</summary>
/// <param name="typeName">Test case input.</param>
[Theory]
[InlineData("Boolean")]
[InlineData("SByte")]
@@ -38,30 +40,35 @@ public class OpcUaDataTypeNameMapTests
Assert.Equal(typeName, OpcUaBuiltInTypeNames.Resolve(nodeId));
}
/// <summary>Verifies that resolve, double, returns double.</summary>
[Fact]
public void Resolve_Double_ReturnsDouble()
{
Assert.Equal("Double", OpcUaBuiltInTypeNames.Resolve(DataTypeIds.Double));
}
/// <summary>Verifies that resolve, int32, returns int32.</summary>
[Fact]
public void Resolve_Int32_ReturnsInt32()
{
Assert.Equal("Int32", OpcUaBuiltInTypeNames.Resolve(DataTypeIds.Int32));
}
/// <summary>Verifies that resolve, boolean, returns boolean.</summary>
[Fact]
public void Resolve_Boolean_ReturnsBoolean()
{
Assert.Equal("Boolean", OpcUaBuiltInTypeNames.Resolve(DataTypeIds.Boolean));
}
/// <summary>Verifies that resolve, string, returns string.</summary>
[Fact]
public void Resolve_String_ReturnsString()
{
Assert.Equal("String", OpcUaBuiltInTypeNames.Resolve(DataTypeIds.String));
}
/// <summary>Verifies that resolve, unknown custom node id, returns node id string.</summary>
[Fact]
public void Resolve_UnknownCustomNodeId_ReturnsNodeIdString()
{
@@ -73,6 +80,7 @@ public class OpcUaDataTypeNameMapTests
Assert.Equal(custom.ToString(), OpcUaBuiltInTypeNames.Resolve(custom));
}
/// <summary>Verifies that resolve, null, returns empty string.</summary>
[Fact]
public void Resolve_Null_ReturnsEmptyString()
{
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
/// <summary>
/// M2.4 (#8) regression: standard OPC UA A&amp;C events carry an event-type
/// Regression: standard OPC UA A&amp;C events carry an event-type
/// <see cref="NodeId"/> (e.g. <c>i=9341</c> for ExclusiveLevelAlarmType), but the
/// client-side conditionFilter gate — and the server-side WhereClause — both key off
/// the friendly type names in <see cref="RealOpcUaClient.KnownConditionTypeIds"/>.
@@ -14,6 +14,7 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
/// </summary>
public class RealOpcUaClientAlarmFilterTests
{
/// <summary>Verifies that a known standard node id resolves to its friendly alarm type name.</summary>
[Fact]
public void ResolveAlarmTypeName_KnownStandardNodeId_ReturnsFriendlyName()
{
@@ -23,6 +24,7 @@ public class RealOpcUaClientAlarmFilterTests
Assert.Equal("ExclusiveLevelAlarmType", resolved);
}
/// <summary>Verifies that a discrete alarm node id resolves to its friendly alarm type name.</summary>
[Fact]
public void ResolveAlarmTypeName_DiscreteAlarmNodeId_ReturnsFriendlyName()
{
@@ -30,6 +32,7 @@ public class RealOpcUaClientAlarmFilterTests
Assert.Equal("DiscreteAlarmType", resolved);
}
/// <summary>Verifies that an unknown custom node id resolves to its node id string.</summary>
[Fact]
public void ResolveAlarmTypeName_UnknownCustomNodeId_ReturnsNodeIdString()
{
@@ -42,12 +45,14 @@ public class RealOpcUaClientAlarmFilterTests
Assert.Equal(custom.ToString(), resolved);
}
/// <summary>Verifies that a null node id resolves to an empty string.</summary>
[Fact]
public void ResolveAlarmTypeName_Null_ReturnsEmptyString()
{
Assert.Equal("", RealOpcUaClient.ResolveAlarmTypeName(null));
}
/// <summary>Verifies that the inverse map round-trips every known condition type.</summary>
[Fact]
public void InverseMap_RoundTrips_EveryKnownConditionType()
{
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
/// <summary>
/// M7-B2 (T15): <see cref="StubOpcUaClient"/> serves a small canned address-space
/// Verifies that <see cref="StubOpcUaClient"/> serves a small canned address-space
/// tree so DCL browse/paging flows can be exercised without a live OPC UA server.
/// The stub also fakes BrowseNext continuation paging on <c>Folder1</c> so the
/// continuation-token round-trip is testable end-to-end against the contract.
@@ -21,6 +21,8 @@ public class StubOpcUaClientBrowseTests
return client;
}
/// <summary>Verifies that browsing the root returns two folders with no continuation token.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task BrowseRoot_ReturnsTwoFolders_NoContinuation()
{
@@ -46,6 +48,8 @@ public class StubOpcUaClientBrowseTests
});
}
/// <summary>Verifies that browsing Folder1 without a token returns the first tag and a continuation token.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task BrowseFolder1_NoToken_ReturnsTag1_AndContinuationToken()
{
@@ -61,6 +65,8 @@ public class StubOpcUaClientBrowseTests
Assert.False(only.HasChildren);
}
/// <summary>Verifies that browsing Folder1 with the continuation token returns the second tag and no further continuation.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task BrowseFolder1_WithContinuationToken_ReturnsTag2_AndNoFurtherContinuation()
{
@@ -78,6 +84,8 @@ public class StubOpcUaClientBrowseTests
Assert.False(only.HasChildren);
}
/// <summary>Verifies that browsing a leaf node returns no children and no continuation token.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task BrowseLeaf_ReturnsEmpty_NoContinuation()
{
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
/// <summary>
/// M7-B4 (T15): <see cref="StubOpcUaClient"/> searches its canned address-space
/// <see cref="StubOpcUaClient"/> searches its canned address-space
/// tree (root → Folder1/Folder2; Folder1 → Tag1/Tag2) so DCL address-space
/// search flows can be exercised without a live OPC UA server. The stub fakes
/// BrowseNext continuation paging on <c>Folder1</c>, so finding both Tag1 and
@@ -21,6 +21,8 @@ public class StubOpcUaClientSearchTests
return client;
}
/// <summary>Verifies that search tag finds both tags with root relative paths.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Search_Tag_FindsBothTags_WithRootRelativePaths()
{
@@ -36,6 +38,8 @@ public class StubOpcUaClientSearchTests
Assert.All(result.Matches, m => Assert.Contains("Tag", m.Node.DisplayName));
}
/// <summary>Verifies that search folder matches on display name and path.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Search_Folder_MatchesOnDisplayNameAndPath()
{
@@ -51,6 +55,8 @@ public class StubOpcUaClientSearchTests
Assert.Equal(new[] { "/Folder1", "/Folder1/Tag1", "/Folder1/Tag2", "/Folder2" }, paths);
}
/// <summary>Verifies that search folder 2 matches only the folder by display name.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Search_Folder2_MatchesOnlyTheFolderByDisplayName()
{
@@ -66,6 +72,8 @@ public class StubOpcUaClientSearchTests
Assert.Equal("Folder2", only.Node.DisplayName);
}
/// <summary>Verifies that search is case insensitive.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Search_IsCaseInsensitive()
{
@@ -76,6 +84,8 @@ public class StubOpcUaClientSearchTests
Assert.Equal(2, result.Matches.Count);
}
/// <summary>Verifies that search max results one stops early with cap reached.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Search_MaxResultsOne_StopsEarly_WithCapReached()
{
@@ -88,6 +98,8 @@ public class StubOpcUaClientSearchTests
Assert.Contains("Tag", only.Node.DisplayName);
}
/// <summary>Verifies that search empty query returns no matches.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Search_EmptyQuery_ReturnsNoMatches()
{
@@ -99,6 +111,8 @@ public class StubOpcUaClientSearchTests
Assert.False(result.CapReached);
}
/// <summary>Verifies that search whitespace query returns no matches.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Search_WhitespaceQuery_ReturnsNoMatches()
{
@@ -31,6 +31,7 @@ public class VerifyEndpointResultMappingTests
return request.CreateSelfSigned(notBefore, notAfter);
}
/// <summary>Verifies that a captured certificate with a validation exception maps to an untrusted-certificate result.</summary>
[Fact]
public void CapturedCert_WithValidationException_MapsToUntrustedCertificate()
{
@@ -55,6 +56,7 @@ public class VerifyEndpointResultMappingTests
Assert.NotNull(result.Error);
}
/// <summary>Verifies that a captured certificate without an exception still maps to an untrusted-certificate result.</summary>
[Fact]
public void CapturedCert_WithoutException_MapsToUntrustedCertificate()
{
@@ -70,6 +72,7 @@ public class VerifyEndpointResultMappingTests
Assert.Equal(cert.Thumbprint, result.Cert!.Thumbprint);
}
/// <summary>Verifies that a timeout exception maps to a timeout result.</summary>
[Fact]
public void TimeoutException_MapsToTimeout()
{
@@ -80,6 +83,7 @@ public class VerifyEndpointResultMappingTests
Assert.Null(result.Cert);
}
/// <summary>Verifies that an operation-canceled exception maps to a timeout result.</summary>
[Fact]
public void OperationCanceledException_MapsToTimeout()
{
@@ -90,6 +94,7 @@ public class VerifyEndpointResultMappingTests
Assert.Null(result.Cert);
}
/// <summary>Verifies that a ServiceResultException with a bad-request timeout code maps to a timeout result.</summary>
[Fact]
public void ServiceResultException_BadRequestTimeout_MapsToTimeout()
{
@@ -101,6 +106,8 @@ public class VerifyEndpointResultMappingTests
Assert.Equal(VerifyFailureKind.Timeout, result.FailureKind);
}
/// <summary>Verifies that ServiceResultException authentication status codes map to an auth-failed result.</summary>
/// <param name="statusCode">The status code.</param>
[Theory]
[InlineData(StatusCodes.BadUserAccessDenied)]
[InlineData(StatusCodes.BadIdentityTokenRejected)]
@@ -116,6 +123,8 @@ public class VerifyEndpointResultMappingTests
Assert.Null(result.Cert);
}
/// <summary>Verifies that ServiceResultException connection status codes map to an unreachable result.</summary>
/// <param name="statusCode">The status code.</param>
[Theory]
[InlineData(StatusCodes.BadConnectionRejected)]
[InlineData(StatusCodes.BadNotConnected)]
@@ -129,6 +138,7 @@ public class VerifyEndpointResultMappingTests
Assert.Equal(VerifyFailureKind.Unreachable, result.FailureKind);
}
/// <summary>Verifies that a socket exception maps to an unreachable result.</summary>
[Fact]
public void SocketException_MapsToUnreachable()
{
@@ -141,6 +151,7 @@ public class VerifyEndpointResultMappingTests
Assert.Equal(VerifyFailureKind.Unreachable, result.FailureKind);
}
/// <summary>Verifies that a socket exception wrapped in a ServiceResultException maps to an unreachable result.</summary>
[Fact]
public void SocketExceptionWrappedInServiceResult_MapsToUnreachable()
{
@@ -156,6 +167,7 @@ public class VerifyEndpointResultMappingTests
Assert.Equal(VerifyFailureKind.Unreachable, result.FailureKind);
}
/// <summary>Verifies that a generic exception maps to a server-error result.</summary>
[Fact]
public void GenericException_MapsToServerError()
{
@@ -167,6 +179,7 @@ public class VerifyEndpointResultMappingTests
Assert.NotNull(result.Error);
}
/// <summary>Verifies that a null exception with no captured certificate maps to a success result.</summary>
[Fact]
public void NullException_NullCert_MapsToSuccess()
{