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